diff --git a/.cursor/plans/normalized_total_score_fix_migration.plan.md b/.cursor/plans/normalized_total_score_fix_migration.plan.md new file mode 100644 index 0000000..229b75a --- /dev/null +++ b/.cursor/plans/normalized_total_score_fix_migration.plan.md @@ -0,0 +1,37 @@ +--- +name: normalized_total_score fix migration +overview: "Новая миграция 000023: пересоздать weekly_report_mv с max_goal_score и удалить max_score из weekly_goals." +todos: + - id: migration-up + content: "Добавить 000023 up: DROP MV, CREATE MV с max_goal_score (из 000020), DROP COLUMN IF EXISTS max_score" + status: pending + - id: migration-down + content: "Добавить 000023 down: восстановить MV со старой формулой (max_score) и колонку max_score" + status: pending + - id: verify-local + content: Применить миграцию локально и проверить Релокация 2026-08 (normalized 32.74 и 21.55) + status: pending +isProject: false +--- + +# План: Исправить normalized_total_score через новую миграцию + +## Проблема + +На проде (и в локальной копии продовой БД) `normalized_total_score` не учитывает `max_goal_score`: в определении материализованного представления `weekly_report_mv` до сих пор используется колонка `wg.max_score`, которая не заполняется (всегда NULL) → формула всегда даёт `normalized_total_score = total_score`. + +## Решение + +Новая миграция (не менять 000020/000022): + +1. **Пересоздать `weekly_report_mv**` с определением из 000020: в формуле использовать `max_goal_score`, тот же подзапрос по `n.created_date` и фильтр «только прошлые недели». +2. **Удалить колонку `max_score` из `weekly_goals**`, если есть: `ALTER TABLE weekly_goals DROP COLUMN IF EXISTS max_score;` + +После применения и `REFRESH` (или при следующем кроне) для прошлых недель normalized будет ограничиваться целями (например, Релокация 2026-08: 39.14 → 32.74). + +## Todos + +- **migration-up** — Добавить миграцию `000023_fix_weekly_report_mv_use_max_goal_score.up.sql`: DROP MV, CREATE MV с max_goal_score (копия определения из 000020), DROP COLUMN IF EXISTS max_score в weekly_goals +- **migration-down** — Добавить `000023_fix_weekly_report_mv_use_max_goal_score.down.sql`: восстановить MV со старой формулой (max_score) и колонку max_score в weekly_goals +- **verify-local** — Применить миграцию локально и проверить по Релокации за 2026-08: normalized_total_score = 32.74 (project_id 27) и 21.55 (project_id 592) + diff --git a/VERSION b/VERSION index 26d99a2..ce7f2b4 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -5.2.1 +5.2.2 diff --git a/play-life-backend/main.go b/play-life-backend/main.go index 60423f2..185bfa0 100644 --- a/play-life-backend/main.go +++ b/play-life-backend/main.go @@ -2963,6 +2963,8 @@ func (a *App) runMigrations() error { databaseURL := fmt.Sprintf("postgres://%s@%s:%s/%s?sslmode=disable", userInfo.String(), dbHost, dbPort, dbName) + log.Printf("Migrations path: %s", migrationsPath) + // Create migrate instance m, err := migrate.New( fmt.Sprintf("file://%s", migrationsPath), @@ -3007,6 +3009,8 @@ func (a *App) runMigrations() error { } log.Printf("Fixed dirty migration state for version %d", currentVersion) // Continue to apply migrations normally + } else { + log.Printf("Current DB migration version: %d", currentVersion) } } diff --git a/play-life-backend/migrations/000023_fix_weekly_report_mv_use_max_goal_score.down.sql b/play-life-backend/migrations/000023_fix_weekly_report_mv_use_max_goal_score.down.sql new file mode 100644 index 0000000..1e87715 --- /dev/null +++ b/play-life-backend/migrations/000023_fix_weekly_report_mv_use_max_goal_score.down.sql @@ -0,0 +1,81 @@ +-- Migration: Rollback to MV using max_score and restore max_score column. + +DROP MATERIALIZED VIEW IF EXISTS projects_median_mv; +ALTER TABLE weekly_goals ADD COLUMN IF NOT EXISTS max_score NUMERIC(10,4); +UPDATE weekly_goals SET max_score = max_goal_score WHERE max_score IS NULL; + +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 + (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; + +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.'; + +-- Recreate projects_median_mv (last 4 weeks per 000008) +CREATE MATERIALIZED VIEW projects_median_mv AS +SELECT + p.id AS project_id, + p.user_id, + PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY normalized_total_score) AS median_score +FROM ( + SELECT + project_id, + normalized_total_score, + report_year, + report_week, + ROW_NUMBER() OVER (PARTITION BY project_id ORDER BY report_year DESC, report_week DESC) as rn + FROM weekly_report_mv + WHERE + (report_year < EXTRACT(ISOYEAR FROM CURRENT_DATE)::INTEGER) + OR (report_year = EXTRACT(ISOYEAR FROM CURRENT_DATE)::INTEGER + AND report_week < EXTRACT(WEEK FROM CURRENT_DATE)::INTEGER) +) sub +JOIN projects p ON p.id = sub.project_id +WHERE rn <= 4 AND p.deleted = FALSE +GROUP BY p.id, p.user_id +WITH DATA; + +CREATE INDEX idx_projects_median_mv_project_id ON projects_median_mv(project_id); +CREATE INDEX idx_projects_median_mv_user_id ON projects_median_mv(user_id); + +COMMENT ON MATERIALIZED VIEW projects_median_mv IS 'Materialized view calculating median score for each project based on last 4 weeks of historical data. Includes user_id for multi-tenant support.'; diff --git a/play-life-backend/migrations/000023_fix_weekly_report_mv_use_max_goal_score.up.sql b/play-life-backend/migrations/000023_fix_weekly_report_mv_use_max_goal_score.up.sql new file mode 100644 index 0000000..4aea141 --- /dev/null +++ b/play-life-backend/migrations/000023_fix_weekly_report_mv_use_max_goal_score.up.sql @@ -0,0 +1,82 @@ +-- Migration: Fix weekly_report_mv to use max_goal_score for normalized_total_score. +-- Safe to run on DBs where 000020 was not applied (MV still uses max_score, column exists but is NULL). +-- projects_median_mv depends on weekly_report_mv, so we drop and recreate it. + +DROP MATERIALIZED VIEW IF EXISTS projects_median_mv; +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_goal_score IS NULL THEN COALESCE(agg.total_score, 0.0000) + ELSE LEAST(COALESCE(agg.total_score, 0.0000), wg.max_goal_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 + (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; + +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_goal_score. Contains only historical data (excludes current week). Uses nodes.created_date (denormalized) instead of entries.created_date.'; + +-- Recreate projects_median_mv (depends on weekly_report_mv, last 4 weeks per 000008) +CREATE MATERIALIZED VIEW projects_median_mv AS +SELECT + p.id AS project_id, + p.user_id, + PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY normalized_total_score) AS median_score +FROM ( + SELECT + project_id, + normalized_total_score, + report_year, + report_week, + ROW_NUMBER() OVER (PARTITION BY project_id ORDER BY report_year DESC, report_week DESC) as rn + FROM weekly_report_mv + WHERE + (report_year < EXTRACT(ISOYEAR FROM CURRENT_DATE)::INTEGER) + OR (report_year = EXTRACT(ISOYEAR FROM CURRENT_DATE)::INTEGER + AND report_week < EXTRACT(WEEK FROM CURRENT_DATE)::INTEGER) +) sub +JOIN projects p ON p.id = sub.project_id +WHERE rn <= 4 AND p.deleted = FALSE +GROUP BY p.id, p.user_id +WITH DATA; + +CREATE INDEX idx_projects_median_mv_project_id ON projects_median_mv(project_id); +CREATE INDEX idx_projects_median_mv_user_id ON projects_median_mv(user_id); + +COMMENT ON MATERIALIZED VIEW projects_median_mv IS 'Materialized view calculating median score for each project based on last 4 weeks of historical data. Includes user_id for multi-tenant support.'; + +ALTER TABLE weekly_goals DROP COLUMN IF EXISTS max_score; diff --git a/play-life-web/package.json b/play-life-web/package.json index e20be6c..f4b7692 100644 --- a/play-life-web/package.json +++ b/play-life-web/package.json @@ -1,6 +1,6 @@ { "name": "play-life-web", - "version": "5.2.1", + "version": "5.2.2", "type": "module", "scripts": { "dev": "vite",