diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..213d2ca --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,8 @@ +{ + "permissions": { + "allow": [ + "Bash(curl -s -o /dev/null -w \"%{http_code}\" http://localhost:8080/priorities/confirm)", + "Bash(curl -s -o /dev/null -w \"%{http_code}\" -X POST http://localhost:8080/priorities/confirm -H \"Content-Type: application/json\" -d '[]')" + ] + } +} diff --git a/VERSION b/VERSION index d7d9d3f..6839049 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -6.13.1 +6.14.0 diff --git a/play-life-backend/main.go b/play-life-backend/main.go index 6eb9395..f3488a8 100644 --- a/play-life-backend/main.go +++ b/play-life-backend/main.go @@ -169,6 +169,7 @@ type WeeklyStatsResponse struct { Projects []WeeklyProjectStats `json:"projects"` Wishes []WishlistItem `json:"wishes,omitempty"` PendingScoresByProject map[int]float64 `json:"pending_scores_by_project,omitempty"` + PrioritiesConfirmed bool `json:"priorities_confirmed"` } type MessagePostRequest struct { @@ -284,11 +285,13 @@ type TelegramUpdate struct { // Tracking structures type TrackingUserStats struct { - UserID int `json:"user_id"` - UserName string `json:"user_name"` - IsCurrentUser bool `json:"is_current_user"` - Total *float64 `json:"total,omitempty"` - Projects []TrackingProjectStats `json:"projects"` + UserID int `json:"user_id"` + UserName string `json:"user_name"` + IsCurrentUser bool `json:"is_current_user"` + Total *float64 `json:"total,omitempty"` + Projects []TrackingProjectStats `json:"projects"` + PrioritiesConfirmedYear int `json:"priorities_confirmed_year"` + PrioritiesConfirmedWeek int `json:"priorities_confirmed_week"` } type TrackingProjectStats struct { @@ -2997,6 +3000,13 @@ func (a *App) getWeeklyStatsHandler(w http.ResponseWriter, r *http.Request) { if pendingByProject == nil { pendingByProject = make(map[int]float64) } + + confirmed, err := a.isPrioritiesConfirmedForUser(userID) + if err != nil { + log.Printf("Error checking priorities confirmation for user %d: %v", userID, err) + confirmed = false + } + response := WeeklyStatsResponse{ Total: total, GroupProgress1: groupsProgress.Group1, @@ -3005,6 +3015,7 @@ func (a *App) getWeeklyStatsHandler(w http.ResponseWriter, r *http.Request) { Projects: projects, Wishes: wishes, PendingScoresByProject: pendingByProject, + PrioritiesConfirmed: confirmed, } w.Header().Set("Content-Type", "application/json") @@ -3220,6 +3231,8 @@ func (a *App) startWeeklyGoalsScheduler() { log.Printf("Project score sample materialized view refreshed successfully") } + + // Затем настраиваем цели на новую неделю if err := a.setupWeeklyGoals(); err != nil { log.Printf("Error in scheduled weekly goals setup: %v", err) @@ -3908,13 +3921,20 @@ func (a *App) getWeeklyStatsDataForUser(userID int) (*WeeklyStatsResponse, error // Фильтруем желания для экрана прогресса недели wishes := a.filterWishesForWeekProgress(allWishes, projectMinGoalScores) + confirmed, err := a.isPrioritiesConfirmedForUser(userID) + if err != nil { + log.Printf("Error checking priorities confirmation for user %d: %v", userID, err) + confirmed = false + } + response := WeeklyStatsResponse{ - Total: total, - GroupProgress1: groupsProgress.Group1, - GroupProgress2: groupsProgress.Group2, - GroupProgress0: groupsProgress.Group0, - Projects: projects, - Wishes: wishes, + Total: total, + GroupProgress1: groupsProgress.Group1, + GroupProgress2: groupsProgress.Group2, + GroupProgress0: groupsProgress.Group0, + Projects: projects, + Wishes: wishes, + PrioritiesConfirmed: confirmed, } return &response, nil @@ -3955,9 +3975,9 @@ func (a *App) formatDailyReport(data *WeeklyStatsResponse) string { } // Форматирование текста целей - // Проверяем, что minGoal валиден (не NaN, как в JS коде: !isNaN(minGoal)) + // Цели не отображаются пока пользователь не подтвердил приоритеты на этой неделе goalText := "" - if !math.IsNaN(minGoal) { + if data.PrioritiesConfirmed && !math.IsNaN(minGoal) { if hasMaxGoal && !math.IsNaN(maxGoal) { goalText = fmt.Sprintf(" (Цель: %.1f–%.1f)", minGoal, maxGoal) } else { @@ -4517,6 +4537,7 @@ func main() { // Note: /weekly_goals/setup, /daily-report/trigger moved to adminAPIRoutes protected.HandleFunc("/projects", app.getProjectsHandler).Methods("GET", "OPTIONS") protected.HandleFunc("/project/priority", app.setProjectPriorityHandler).Methods("POST", "OPTIONS") + protected.HandleFunc("/priorities/confirm", app.confirmPrioritiesHandler).Methods("POST", "OPTIONS") protected.HandleFunc("/project/color", app.setProjectColorHandler).Methods("POST", "OPTIONS") protected.HandleFunc("/project/move", app.moveProjectHandler).Methods("POST", "OPTIONS") protected.HandleFunc("/project/delete", app.deleteProjectHandler).Methods("POST", "OPTIONS") @@ -5561,6 +5582,115 @@ func (a *App) setupWeeklyGoals() error { return nil } +// isPrioritiesConfirmedForUser проверяет, подтвердил ли пользователь приоритеты на текущей ISO-неделе +func (a *App) isPrioritiesConfirmedForUser(userID int) (bool, error) { + timezoneStr := getEnv("TIMEZONE", "UTC") + loc, err := time.LoadLocation(timezoneStr) + if err != nil { + loc = time.UTC + } + now := time.Now().In(loc) + currentYear, currentWeek := now.ISOWeek() + + var confirmedYear, confirmedWeek int + err = a.DB.QueryRow( + `SELECT priorities_confirmed_year, priorities_confirmed_week FROM users WHERE id = $1`, + userID, + ).Scan(&confirmedYear, &confirmedWeek) + if err != nil { + return false, err + } + return confirmedYear == currentYear && confirmedWeek == currentWeek, nil +} + +// setPrioritiesConfirmed помечает приоритеты пользователя как подтверждённые на текущей ISO-неделе +func (a *App) setPrioritiesConfirmed(userID int) error { + timezoneStr := getEnv("TIMEZONE", "UTC") + loc, err := time.LoadLocation(timezoneStr) + if err != nil { + loc = time.UTC + } + now := time.Now().In(loc) + currentYear, currentWeek := now.ISOWeek() + + _, err = a.DB.Exec( + `UPDATE users SET priorities_confirmed_year = $1, priorities_confirmed_week = $2 WHERE id = $3`, + currentYear, currentWeek, userID, + ) + return err +} + +// setupWeeklyGoalsForUser устанавливает цели на текущую неделю для одного пользователя +func (a *App) setupWeeklyGoalsForUser(userID int) error { + setupQuery := ` + WITH current_info AS ( + SELECT + EXTRACT(ISOYEAR FROM CURRENT_DATE)::INTEGER AS c_year, + EXTRACT(WEEK FROM CURRENT_DATE)::INTEGER AS c_week + ), + goal_metrics AS ( + SELECT + project_id, + AVG(normalized_total_score) AS avg_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 + WHERE rn <= 4 + GROUP BY project_id + ) + INSERT INTO weekly_goals ( + project_id, + goal_year, + goal_week, + min_goal_score, + max_goal_score, + priority, + user_id + ) + SELECT + p.id, + ci.c_year, + ci.c_week, + COALESCE(gm.avg_score, 0) AS min_goal_score, + CASE + WHEN gm.avg_score IS NULL THEN NULL + WHEN p.priority = 1 THEN gm.avg_score * 2.0 + WHEN p.priority = 2 THEN gm.avg_score * 1.7 + ELSE gm.avg_score * 1.4 + END AS max_goal_score, + p.priority, + p.user_id + FROM projects p + CROSS JOIN current_info ci + LEFT JOIN goal_metrics gm ON p.id = gm.project_id + WHERE p.deleted = FALSE AND p.user_id = $1 + ON CONFLICT (project_id, goal_year, goal_week) DO UPDATE + SET + min_goal_score = EXCLUDED.min_goal_score, + max_goal_score = EXCLUDED.max_goal_score, + priority = EXCLUDED.priority, + user_id = EXCLUDED.user_id + ` + + _, err := a.DB.Exec(setupQuery, userID) + if err != nil { + return fmt.Errorf("error setting up weekly goals for user %d: %w", userID, err) + } + + log.Printf("Weekly goals setup completed for user %d", userID) + return nil +} + // getWeeklyGoalsForUser получает цели для конкретного пользователя func (a *App) getWeeklyGoalsForUser(userID int) ([]WeeklyGoalSetup, error) { selectQuery := ` @@ -6195,6 +6325,117 @@ func (a *App) setProjectPriorityHandler(w http.ResponseWriter, r *http.Request) }) } +// confirmPrioritiesHandler сохраняет приоритеты и помечает их как подтверждённые на текущей неделе. +// Если приоритеты ещё не подтверждались на этой неделе — также пересчитывает цели. +func (a *App) confirmPrioritiesHandler(w http.ResponseWriter, r *http.Request) { + if r.Method == "OPTIONS" { + setCORSHeaders(w) + w.WriteHeader(http.StatusOK) + return + } + setCORSHeaders(w) + + userID, ok := getUserIDFromContext(r) + if !ok { + sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized) + return + } + + bodyBytes, err := io.ReadAll(r.Body) + if err != nil { + sendErrorWithCORS(w, "Error reading request body", http.StatusBadRequest) + return + } + defer r.Body.Close() + + var projectsToUpdate []ProjectPriorityUpdate + + var directArray []interface{} + arrayErr := json.Unmarshal(bodyBytes, &directArray) + if arrayErr == nil && len(directArray) > 0 { + for _, item := range directArray { + if itemMap, ok := item.(map[string]interface{}); ok { + var project ProjectPriorityUpdate + if idVal, ok := itemMap["id"].(float64); ok { + project.ID = int(idVal) + } else { + continue + } + if priorityVal, ok := itemMap["priority"]; ok && priorityVal != nil { + if numVal, ok := priorityVal.(float64); ok { + priorityInt := int(numVal) + project.Priority = &priorityInt + } else { + project.Priority = nil + } + } else { + project.Priority = nil + } + projectsToUpdate = append(projectsToUpdate, project) + } + } + } + + if len(projectsToUpdate) == 0 { + sendErrorWithCORS(w, "No projects to update", http.StatusBadRequest) + return + } + + // Проверяем, подтверждены ли уже приоритеты на этой неделе + alreadyConfirmed, err := a.isPrioritiesConfirmedForUser(userID) + if err != nil { + log.Printf("Error checking priorities confirmation for user %d: %v", userID, err) + alreadyConfirmed = false + } + + // Сохраняем приоритеты в транзакции + tx, err := a.DB.Begin() + if err != nil { + sendErrorWithCORS(w, "Error beginning transaction", http.StatusInternalServerError) + return + } + defer tx.Rollback() + + for _, project := range projectsToUpdate { + if project.Priority == nil { + _, err = tx.Exec(`UPDATE projects SET priority = NULL WHERE id = $1 AND user_id = $2`, project.ID, userID) + } else { + _, err = tx.Exec(`UPDATE projects SET priority = $1 WHERE id = $2 AND user_id = $3`, *project.Priority, project.ID, userID) + } + if err != nil { + tx.Rollback() + sendErrorWithCORS(w, fmt.Sprintf("Error updating project %d", project.ID), http.StatusInternalServerError) + return + } + } + + if err := tx.Commit(); err != nil { + sendErrorWithCORS(w, "Error committing transaction", http.StatusInternalServerError) + return + } + + // Помечаем приоритеты как подтверждённые + if err := a.setPrioritiesConfirmed(userID); err != nil { + log.Printf("Error setting priorities confirmed for user %d: %v", userID, err) + } + + // Пересчитываем цели только если ещё не подтверждали на этой неделе + goalsUpdated := false + if !alreadyConfirmed { + if err := a.setupWeeklyGoalsForUser(userID); err != nil { + log.Printf("Error setting up weekly goals for user %d: %v", userID, err) + } else { + goalsUpdated = true + } + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "message": "Priorities confirmed", + "goals_updated": goalsUpdated, + }) +} + type ProjectColorRequest struct { ID int `json:"id"` Color string `json:"color"` @@ -17466,12 +17707,23 @@ func (a *App) getTrackingStatsHandler(w http.ResponseWriter, r *http.Request) { return pi < pj }) + var confirmedYear, confirmedWeek int + err = a.DB.QueryRow( + `SELECT priorities_confirmed_year, priorities_confirmed_week FROM users WHERE id = $1`, + uid, + ).Scan(&confirmedYear, &confirmedWeek) + if err != nil { + log.Printf("Error getting priorities confirmed for user %d: %v", uid, err) + } + users = append(users, TrackingUserStats{ - UserID: uid, - UserName: userName, - IsCurrentUser: uid == userID, - Total: stats.Total, - Projects: projects, + UserID: uid, + UserName: userName, + IsCurrentUser: uid == userID, + Total: stats.Total, + Projects: projects, + PrioritiesConfirmedYear: confirmedYear, + PrioritiesConfirmedWeek: confirmedWeek, }) } diff --git a/play-life-backend/migrations/000030_add_priorities_confirmed.down.sql b/play-life-backend/migrations/000030_add_priorities_confirmed.down.sql new file mode 100644 index 0000000..c707ef1 --- /dev/null +++ b/play-life-backend/migrations/000030_add_priorities_confirmed.down.sql @@ -0,0 +1,3 @@ +ALTER TABLE users + DROP COLUMN IF EXISTS priorities_confirmed_year, + DROP COLUMN IF EXISTS priorities_confirmed_week; diff --git a/play-life-backend/migrations/000030_add_priorities_confirmed.up.sql b/play-life-backend/migrations/000030_add_priorities_confirmed.up.sql new file mode 100644 index 0000000..3936934 --- /dev/null +++ b/play-life-backend/migrations/000030_add_priorities_confirmed.up.sql @@ -0,0 +1,3 @@ +ALTER TABLE users + ADD COLUMN priorities_confirmed_year INTEGER NOT NULL DEFAULT 0, + ADD COLUMN priorities_confirmed_week INTEGER NOT NULL DEFAULT 0; diff --git a/play-life-web/nginx.conf b/play-life-web/nginx.conf index 6ce1a5d..d3ef7f0 100644 --- a/play-life-web/nginx.conf +++ b/play-life-web/nginx.conf @@ -50,7 +50,7 @@ server { } # Proxy other API endpoints to backend - location ~ ^/(playlife-feed|d2dc349a-0d13-49b2-a8f0-1ab094bfba9b|projects|message/post|webhook/|weekly_goals/setup|project_score_sample_mv/refresh)$ { + location ~ ^/(playlife-feed|d2dc349a-0d13-49b2-a8f0-1ab094bfba9b|projects|message/post|webhook/|weekly_goals/setup|project_score_sample_mv/refresh|priorities/confirm)$ { proxy_pass http://backend:8080; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; diff --git a/play-life-web/package.json b/play-life-web/package.json index 359ce45..2e31fa0 100644 --- a/play-life-web/package.json +++ b/play-life-web/package.json @@ -1,6 +1,6 @@ { "name": "play-life-web", - "version": "6.13.1", + "version": "6.14.0", "type": "module", "scripts": { "dev": "vite", diff --git a/play-life-web/src/App.jsx b/play-life-web/src/App.jsx index a5878b0..845d3a2 100644 --- a/play-life-web/src/App.jsx +++ b/play-life-web/src/App.jsx @@ -134,6 +134,10 @@ function AppContent() { // Ref для функции открытия модала добавления записи в CurrentWeek const currentWeekAddModalRef = useRef(null) + // Подтверждение приоритетов на текущей неделе (null = неизвестно, true/false = известно) + const [prioritiesConfirmed, setPrioritiesConfirmed] = useState(null) + const prioritiesOverlayPushedRef = useRef(false) + // Кеширование данных const [currentWeekData, setCurrentWeekData] = useState(null) const [fullStatisticsData, setFullStatisticsData] = useState(null) @@ -173,7 +177,22 @@ function AppContent() { // Восстанавливаем последний выбранный таб после перезагрузки const [isInitialized, setIsInitialized] = useState(false) - + + // Управление историей для оверлея приоритетов + useEffect(() => { + const overlayVisible = activeTab === 'current' && prioritiesConfirmed === false + if (overlayVisible && !prioritiesOverlayPushedRef.current) { + prioritiesOverlayPushedRef.current = true + // Заменяем текущую запись { tab: 'current' } на { tab: 'tasks' }, + // затем добавляем запись оверлея. Так системная кнопка "Назад" вернёт на tasks. + window.history.replaceState({ tab: 'tasks' }, '', '/') + window.history.pushState({ tab: 'current', prioritiesOverlay: true }, '', '/') + } + if (!overlayVisible) { + prioritiesOverlayPushedRef.current = false + } + }, [activeTab, prioritiesConfirmed]) + // Переключение на экран прогрессии после успешной авторизации useEffect(() => { // Обновляем ref только после того, как authLoading стал false @@ -199,6 +218,7 @@ function AppContent() { setFullStatisticsData(null) setTasksData(null) setTodayEntriesData(null) + setPrioritiesConfirmed(null) // Сбрасываем инициализацию табов, чтобы данные загрузились заново Object.keys(tabsInitializedRef.current).forEach(key => { tabsInitializedRef.current[key] = false @@ -506,6 +526,9 @@ function AppContent() { const wishes = jsonData?.wishes || [] const pendingScoresByProject = jsonData?.pending_scores_by_project && typeof jsonData.pending_scores_by_project === 'object' ? jsonData.pending_scores_by_project : {} + const rootData = (Array.isArray(jsonData) && jsonData.length > 0) ? jsonData[0] : jsonData + const prioritiesConfirmedValue = rootData?.priorities_confirmed ?? null + setCurrentWeekData({ projects: Array.isArray(projects) ? projects : [], total: total, @@ -515,6 +538,10 @@ function AppContent() { wishes: wishes, pending_scores_by_project: pendingScoresByProject }) + + if (prioritiesConfirmedValue !== null) { + setPrioritiesConfirmed(prioritiesConfirmedValue) + } } catch (err) { setCurrentWeekError(err.message) console.error('Ошибка загрузки данных текущей недели:', err) @@ -796,7 +823,7 @@ function AppContent() { } const validTabs = ['current', 'priorities', 'full', 'words', 'add-words', 'dictionaries', 'test', 'purchase', 'tasks', 'task-form', 'wishlist', 'wishlist-form', 'wishlist-detail', 'profile', 'todoist-integration', 'telegram-integration', 'tracking', 'tracking-access', 'tracking-invite', 'shopping', 'shopping-item-form', 'shopping-board-form', 'shopping-board-join', 'shopping-item-history'] - + // Проверяем state текущей записи истории (куда мы вернулись) if (event.state && event.state.tab) { const { tab, params = {} } = event.state @@ -1123,6 +1150,8 @@ function AppContent() { let paddingClasses = '' if (tabName === 'current' || tabName === 'tasks' || tabName === 'wishlist' || tabName === 'profile') { paddingClasses = 'pb-20' + } else if (tabName === 'priorities') { + paddingClasses = 'pb-20' } else if (tabName === 'words' || tabName === 'dictionaries' || tabName === 'shopping') { paddingClasses = 'pb-16' } @@ -1171,7 +1200,7 @@ function AppContent() { {loadedTabs.priorities && (