From 6e9e2db23e4b27fc49d6c1e89b5688b63849aa8d Mon Sep 17 00:00:00 2001 From: poignatov Date: Thu, 5 Feb 2026 18:36:14 +0300 Subject: [PATCH] =?UTF-8?q?4.23.0:=20=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=BE=20=D0=BE=D1=82=D1=81=D0=BB=D0=B5=D0=B6=D0=B8?= =?UTF-8?q?=D0=B2=D0=B0=D0=BD=D0=B8=D0=B5=20=D0=B8=20=D1=83=D0=BB=D1=83?= =?UTF-8?q?=D1=87=D1=88=D0=B5=D0=BD=20UI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- VERSION | 2 +- docker-compose.yml | 6 + play-life-backend/main.go | 894 ++++++++++++++++-- .../000013_add_user_tracking.down.sql | 2 + .../000013_add_user_tracking.up.sql | 24 + play-life-web/package.json | 2 +- play-life-web/src/App.jsx | 60 +- play-life-web/src/components/Profile.jsx | 49 +- play-life-web/src/components/Tracking.css | 435 +++++++++ play-life-web/src/components/Tracking.jsx | 184 ++++ .../src/components/TrackingAccess.jsx | 148 +++ .../src/components/TrackingInviteAccept.jsx | 115 +++ 12 files changed, 1817 insertions(+), 104 deletions(-) create mode 100644 play-life-backend/migrations/000013_add_user_tracking.down.sql create mode 100644 play-life-backend/migrations/000013_add_user_tracking.up.sql create mode 100644 play-life-web/src/components/Tracking.css create mode 100644 play-life-web/src/components/Tracking.jsx create mode 100644 play-life-web/src/components/TrackingAccess.jsx create mode 100644 play-life-web/src/components/TrackingInviteAccept.jsx diff --git a/VERSION b/VERSION index d7638f3..58fe352 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -4.22.0 +4.23.0 diff --git a/docker-compose.yml b/docker-compose.yml index 4c40684..e6fcf86 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -14,6 +14,8 @@ services: POSTGRES_DB: ${DB_NAME:-playeng} ports: - "${DB_PORT:-5432}:5432" + volumes: + - postgres_data:/var/lib/postgresql/data healthcheck: test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-playeng}"] interval: 10s @@ -59,6 +61,10 @@ services: env_file: - .env +volumes: + postgres_data: + name: play-life_postgres_data + networks: default: name: play-life-network diff --git a/play-life-backend/main.go b/play-life-backend/main.go index 49bb2e4..2bd8ffb 100644 --- a/play-life-backend/main.go +++ b/play-life-backend/main.go @@ -270,6 +270,49 @@ type TelegramUpdate struct { EditedMessage *TelegramMessage `json:"edited_message,omitempty"` } +// 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"` +} + +type TrackingProjectStats struct { + ProjectName string `json:"project_name"` + CalculatedScore float64 `json:"calculated_score"` // процент выполнения + Priority *int `json:"priority,omitempty"` +} + +type TrackingStatsResponse struct { + WeekNumber int `json:"week_number"` + Year int `json:"year"` + Users []TrackingUserStats `json:"users"` +} + +type TrackingAccessResponse struct { + Trackers []TrackingUser `json:"trackers"` // кто меня отслеживает + Tracked []TrackingUser `json:"tracked"` // кого я отслеживаю +} + +type TrackingUser struct { + ID int `json:"id"` + RelationID int `json:"relation_id"` // id записи в user_tracking для удаления + Name string `json:"name"` + Email string `json:"email"` + CreatedAt time.Time `json:"created_at"` +} + +type TrackingInviteInfo struct { + UserID int `json:"user_id"` + UserName string `json:"user_name"` +} + +type TrackingInviteResponse struct { + InviteURL string `json:"invite_url"` +} + // Task structures type Task struct { ID int `json:"id"` @@ -432,7 +475,7 @@ type UnlockConditionDisplay struct { TaskID *int `json:"task_id,omitempty"` // ID задачи (для task_completion) TaskName *string `json:"task_name,omitempty"` TaskNextShowAt *string `json:"task_next_show_at,omitempty"` // Дата следующего показа задачи (для task_completion) - ProjectID *int `json:"project_id,omitempty"` // ID проекта (для project_points) + ProjectID *int `json:"project_id,omitempty"` // ID проекта (для project_points) ProjectName *string `json:"project_name,omitempty"` RequiredPoints *float64 `json:"required_points,omitempty"` StartDate *string `json:"start_date,omitempty"` // Дата начала подсчёта (YYYY-MM-DD), NULL = за всё время @@ -2698,28 +2741,39 @@ func (a *App) getWeeklyStatsHandler(w http.ResponseWriter, r *http.Request) { } // Параметры бонуса в зависимости от priority - var extraBonusLimit float64 = 40 + var extraBonusLimit float64 = 20 if priorityVal == 1 { - extraBonusLimit = 100 + extraBonusLimit = 50 } else if priorityVal == 2 { - extraBonusLimit = 70 + extraBonusLimit = 35 } - // Расчет базового прогресса - var baseProgress float64 - if minGoalScoreVal > 0 { - baseProgress = (min(totalScore, minGoalScoreVal) / minGoalScoreVal) * 100.0 + // Расчет calculated_score по логике фронтенда + // min_goal -> 100%, max_goal -> 150%/135%/120% в зависимости от приоритета + var resultScore float64 + if minGoalScoreVal <= 0 { + // Если нет minGoal, возвращаем 0 (или можно относительно maxGoal, но обычно 0) + resultScore = 0 + } else if totalScore < minGoalScoreVal { + // До достижения minGoal растем линейно от 0 до 100% + resultScore = (totalScore / minGoalScoreVal) * 100.0 + } else { + // Достигнут minGoal - базовый прогресс = 100% + baseProgress := 100.0 + + // Если maxGoal задан корректно и больше minGoal, добавляем экстра прогресс + if maxGoalScoreVal > minGoalScoreVal { + extraRange := maxGoalScoreVal - minGoalScoreVal + excess := min(totalScore, maxGoalScoreVal) - minGoalScoreVal + extraRatio := min(1.0, max(0.0, excess/extraRange)) + extraProgress := extraRatio * extraBonusLimit + resultScore = min(100.0+extraBonusLimit, baseProgress+extraProgress) + } else { + // Если maxGoal не задан или некорректен, просто 100% + resultScore = baseProgress + } } - // Расчет экстра прогресса - var extraProgress float64 - denominator := maxGoalScoreVal - minGoalScoreVal - if denominator > 0 && totalScore > minGoalScoreVal { - excess := min(totalScore, maxGoalScoreVal) - minGoalScoreVal - extraProgress = (excess / denominator) * extraBonusLimit - } - - resultScore := baseProgress + extraProgress project.CalculatedScore = roundToTwoDecimals(resultScore) // Группировка для итогового расчета @@ -2738,7 +2792,7 @@ func (a *App) getWeeklyStatsHandler(w http.ResponseWriter, r *http.Request) { groupsProgress := calculateGroupsProgress(groups) // Вычисляем общий процент выполнения - total := calculateOverallProgress(groupsProgress) + total := calculateOverallProgress(groupsProgress, groups) response := WeeklyStatsResponse{ Total: total, @@ -3232,28 +3286,39 @@ func (a *App) getWeeklyStatsData() (*WeeklyStatsResponse, error) { } // Параметры бонуса в зависимости от priority - var extraBonusLimit float64 = 40 + var extraBonusLimit float64 = 20 if priorityVal == 1 { - extraBonusLimit = 100 + extraBonusLimit = 50 } else if priorityVal == 2 { - extraBonusLimit = 70 + extraBonusLimit = 35 } - // Расчет базового прогресса - var baseProgress float64 - if minGoalScoreVal > 0 { - baseProgress = (min(totalScore, minGoalScoreVal) / minGoalScoreVal) * 100.0 + // Расчет calculated_score по логике фронтенда + // min_goal -> 100%, max_goal -> 150%/135%/120% в зависимости от приоритета + var resultScore float64 + if minGoalScoreVal <= 0 { + // Если нет minGoal, возвращаем 0 (или можно относительно maxGoal, но обычно 0) + resultScore = 0 + } else if totalScore < minGoalScoreVal { + // До достижения minGoal растем линейно от 0 до 100% + resultScore = (totalScore / minGoalScoreVal) * 100.0 + } else { + // Достигнут minGoal - базовый прогресс = 100% + baseProgress := 100.0 + + // Если maxGoal задан корректно и больше minGoal, добавляем экстра прогресс + if maxGoalScoreVal > minGoalScoreVal { + extraRange := maxGoalScoreVal - minGoalScoreVal + excess := min(totalScore, maxGoalScoreVal) - minGoalScoreVal + extraRatio := min(1.0, max(0.0, excess/extraRange)) + extraProgress := extraRatio * extraBonusLimit + resultScore = min(100.0+extraBonusLimit, baseProgress+extraProgress) + } else { + // Если maxGoal не задан или некорректен, просто 100% + resultScore = baseProgress + } } - // Расчет экстра прогресса - var extraProgress float64 - denominator := maxGoalScoreVal - minGoalScoreVal - if denominator > 0 && totalScore > minGoalScoreVal { - excess := min(totalScore, maxGoalScoreVal) - minGoalScoreVal - extraProgress = (excess / denominator) * extraBonusLimit - } - - resultScore := baseProgress + extraProgress project.CalculatedScore = roundToTwoDecimals(resultScore) // Группировка для итогового расчета @@ -3272,7 +3337,7 @@ func (a *App) getWeeklyStatsData() (*WeeklyStatsResponse, error) { groupsProgress := calculateGroupsProgress(groups) // Вычисляем общий процент выполнения - total := calculateOverallProgress(groupsProgress) + total := calculateOverallProgress(groupsProgress, groups) response := WeeklyStatsResponse{ Total: total, @@ -3392,28 +3457,39 @@ func (a *App) getWeeklyStatsDataForUser(userID int) (*WeeklyStatsResponse, error } // Параметры бонуса в зависимости от priority - var extraBonusLimit float64 = 40 + var extraBonusLimit float64 = 20 if priorityVal == 1 { - extraBonusLimit = 100 + extraBonusLimit = 50 } else if priorityVal == 2 { - extraBonusLimit = 70 + extraBonusLimit = 35 } - // Расчет базового прогресса - var baseProgress float64 - if minGoalScoreVal > 0 { - baseProgress = (min(totalScore, minGoalScoreVal) / minGoalScoreVal) * 100.0 + // Расчет calculated_score по логике фронтенда + // min_goal -> 100%, max_goal -> 150%/135%/120% в зависимости от приоритета + var resultScore float64 + if minGoalScoreVal <= 0 { + // Если нет minGoal, возвращаем 0 (или можно относительно maxGoal, но обычно 0) + resultScore = 0 + } else if totalScore < minGoalScoreVal { + // До достижения minGoal растем линейно от 0 до 100% + resultScore = (totalScore / minGoalScoreVal) * 100.0 + } else { + // Достигнут minGoal - базовый прогресс = 100% + baseProgress := 100.0 + + // Если maxGoal задан корректно и больше minGoal, добавляем экстра прогресс + if maxGoalScoreVal > minGoalScoreVal { + extraRange := maxGoalScoreVal - minGoalScoreVal + excess := min(totalScore, maxGoalScoreVal) - minGoalScoreVal + extraRatio := min(1.0, max(0.0, excess/extraRange)) + extraProgress := extraRatio * extraBonusLimit + resultScore = min(100.0+extraBonusLimit, baseProgress+extraProgress) + } else { + // Если maxGoal не задан или некорректен, просто 100% + resultScore = baseProgress + } } - // Расчет экстра прогресса - var extraProgress float64 - denominator := maxGoalScoreVal - minGoalScoreVal - if denominator > 0 && totalScore > minGoalScoreVal { - excess := min(totalScore, maxGoalScoreVal) - minGoalScoreVal - extraProgress = (excess / denominator) * extraBonusLimit - } - - resultScore := baseProgress + extraProgress project.CalculatedScore = roundToTwoDecimals(resultScore) projects = append(projects, project) @@ -3431,7 +3507,7 @@ func (a *App) getWeeklyStatsDataForUser(userID int) (*WeeklyStatsResponse, error groupsProgress := calculateGroupsProgress(groups) // Вычисляем общий процент выполнения - total := calculateOverallProgress(groupsProgress) + total := calculateOverallProgress(groupsProgress, groups) response := WeeklyStatsResponse{ Total: total, @@ -3977,6 +4053,15 @@ func main() { protected.HandleFunc("/api/wishlist/invite/{token}", app.getBoardInviteInfoHandler).Methods("GET", "OPTIONS") protected.HandleFunc("/api/wishlist/invite/{token}/join", app.joinBoardHandler).Methods("POST", "OPTIONS") + // Tracking + protected.HandleFunc("/api/tracking/stats", app.getTrackingStatsHandler).Methods("GET", "OPTIONS") + protected.HandleFunc("/api/tracking/invite", app.createTrackingInviteHandler).Methods("POST", "OPTIONS") + protected.HandleFunc("/api/tracking/invite/{token}", app.getTrackingInviteInfoHandler).Methods("GET", "OPTIONS") + protected.HandleFunc("/api/tracking/invite/{token}/accept", app.acceptTrackingInviteHandler).Methods("POST", "OPTIONS") + protected.HandleFunc("/api/tracking/access", app.getTrackingAccessHandler).Methods("GET", "OPTIONS") + protected.HandleFunc("/api/tracking/trackers/{id}", app.deleteTrackingTrackerHandler).Methods("DELETE", "OPTIONS") + protected.HandleFunc("/api/tracking/tracked/{id}", app.deleteTrackingTrackedHandler).Methods("DELETE", "OPTIONS") + // Wishlist items (после boards, чтобы {id} не перехватывал "boards") protected.HandleFunc("/api/wishlist/{id}", app.getWishlistItemHandler).Methods("GET", "OPTIONS") protected.HandleFunc("/api/wishlist/{id}", app.updateWishlistHandler).Methods("PUT", "OPTIONS") @@ -4091,6 +4176,7 @@ func roundToFourDecimals(val float64) float64 { // groups - карта приоритетов к спискам calculatedScore проектов // Возвращает структуру GroupsProgress с процентами для каждой группы // Если какая-то группа отсутствует, она считается как 100% +// min_goal = 100%, max_goal = 150%/135%/120% в зависимости от приоритета func calculateGroupsProgress(groups map[int][]float64) GroupsProgress { // Всего есть 3 группы: приоритет 1, приоритет 2, приоритет 0 // Вычисляем среднее для каждой группы, если она есть @@ -4109,34 +4195,22 @@ func calculateGroupsProgress(groups map[int][]float64) GroupsProgress { // Если группы нет, считаем как 100% avg = 100.0 } else { - // Вычисляем среднее для группы - // Для всех приоритетов: если calculated_score > 100%, избыточная часть делится на 2 - // Функция для корректировки score: если > 100%, то 100 + (score - 100) / 2 - adjustScore := func(score float64) float64 { - if score > 100.0 { - return 100.0 + (score-100.0)/2.0 - } - return score - } - - // Для приоритета 1 и 2 - обычное среднее с корректировкой + // Для приоритета 1 и 2 - обычное среднее if priorityVal == 1 || priorityVal == 2 { sum := 0.0 for _, score := range scores { - sum += adjustScore(score) + sum += score } avg = sum / float64(len(scores)) } else { - // Для проектов без приоритета (priorityVal == 0) - специальная формула с корректировкой + // Для проектов без приоритета (priorityVal == 0) - специальная формула projectCount := float64(len(scores)) multiplier := 100.0 / (projectCount * 0.8) sum := 0.0 for _, score := range scores { - // Применяем корректировку перед использованием в формуле - adjustedScore := adjustScore(score) // score уже в процентах (например, 80.0), переводим в долю (0.8) - scoreAsDecimal := adjustedScore / 100.0 + scoreAsDecimal := score / 100.0 sum += scoreAsDecimal * multiplier } @@ -4161,33 +4235,37 @@ func calculateGroupsProgress(groups map[int][]float64) GroupsProgress { // calculateOverallProgress вычисляет общий процент выполнения на основе процентов групп // groupsProgress - структура с процентами для каждой группы приоритетов +// groups - карта приоритетов к спискам calculatedScore проектов (используется для точного расчета) // Возвращает указатель на float64 с общим процентом выполнения -// Всегда вычисляет среднее всех трех групп (даже если какая-то группа отсутствует, она считается как 100%) -func calculateOverallProgress(groupsProgress GroupsProgress) *float64 { - // Находим среднее между всеми тремя группами - // Если какая-то группа отсутствует (nil), считаем её как 100% - - var group1Val, group2Val, group0Val float64 +// Вычисляет среднее между группами (min_goal = 100%, max_goal = 150%/135%/120%) +func calculateOverallProgress(groupsProgress GroupsProgress, groups map[int][]float64) *float64 { + // Собираем проценты по группам + var groupScores []float64 + // Добавляем проценты только тех групп, которые существуют (имеют проекты) if groupsProgress.Group1 != nil { - group1Val = *groupsProgress.Group1 - } else { - group1Val = 100.0 + groupScores = append(groupScores, *groupsProgress.Group1) } - if groupsProgress.Group2 != nil { - group2Val = *groupsProgress.Group2 - } else { - group2Val = 100.0 + groupScores = append(groupScores, *groupsProgress.Group2) } - if groupsProgress.Group0 != nil { - group0Val = *groupsProgress.Group0 - } else { - group0Val = 100.0 + groupScores = append(groupScores, *groupsProgress.Group0) } - overallProgress := (group1Val + group2Val + group0Val) / 3.0 // Всегда делим на 3, так как групп всегда 3 + // Если нет групп с проектами, возвращаем 0 + if len(groupScores) == 0 { + zero := 0.0 + return &zero + } + + // Вычисляем среднее между группами + var sum float64 + for _, score := range groupScores { + sum += score + } + + overallProgress := sum / float64(len(groupScores)) overallProgressRounded := roundToFourDecimals(overallProgress) total := &overallProgressRounded @@ -14623,6 +14701,650 @@ func (a *App) proxyImageHandler(w http.ResponseWriter, r *http.Request) { w.Write(bodyBytes) } +// ============================================ +// Tracking handlers +// ============================================ + +// getWeeklyStatsDataForUserAndWeek получает данные о проектах для конкретного пользователя и недели +func (a *App) getWeeklyStatsDataForUserAndWeek(userID int, year int, week int) (*WeeklyStatsResponse, error) { + // Определяем, является ли это текущей неделей + now := time.Now() + currentYear, currentWeek := now.ISOWeek() + isCurrentWeek := year == currentYear && week == currentWeek + + var currentWeekScores map[int]float64 + var err error + + if isCurrentWeek { + // Для текущей недели используем realtime данные + currentWeekScores, err = a.getCurrentWeekScores(userID) + if err != nil { + log.Printf("Error getting current week scores: %v", err) + return nil, fmt.Errorf("error getting current week scores: %w", err) + } + } else { + // Для исторических недель используем пустой map (данные из MV) + currentWeekScores = make(map[int]float64) + } + + query := ` + SELECT + p.id AS project_id, + p.name AS project_name, + COALESCE(wr.total_score, 0.0000) AS total_score, + wg.min_goal_score, + wg.max_goal_score, + wg.priority AS priority, + p.color + FROM + projects p + LEFT JOIN + weekly_goals wg ON wg.project_id = p.id + AND wg.goal_year = $2 + AND wg.goal_week = $3 + LEFT JOIN + weekly_report_mv wr + ON p.id = wr.project_id + AND $2 = wr.report_year + AND $3 = wr.report_week + WHERE + p.deleted = FALSE AND p.user_id = $1 + ORDER BY + total_score DESC + ` + + rows, err := a.DB.Query(query, userID, year, week) + if err != nil { + return nil, fmt.Errorf("error querying weekly stats: %w", err) + } + defer rows.Close() + + projects := make([]WeeklyProjectStats, 0) + groups := make(map[int][]float64) + + for rows.Next() { + var project WeeklyProjectStats + var projectID int + var minGoalScore sql.NullFloat64 + var maxGoalScore sql.NullFloat64 + var priority sql.NullInt64 + + err := rows.Scan( + &projectID, + &project.ProjectName, + &project.TotalScore, + &minGoalScore, + &maxGoalScore, + &priority, + &project.Color, + ) + if err != nil { + return nil, fmt.Errorf("error scanning weekly stats row: %w", err) + } + + // Объединяем данные: если это текущая неделя и есть данные, используем их вместо MV + if isCurrentWeek { + if currentWeekScore, exists := currentWeekScores[projectID]; exists { + project.TotalScore = currentWeekScore + } + } + + if minGoalScore.Valid { + project.MinGoalScore = minGoalScore.Float64 + } else { + project.MinGoalScore = 0 + } + + if maxGoalScore.Valid { + maxGoalVal := maxGoalScore.Float64 + project.MaxGoalScore = &maxGoalVal + } + + var priorityVal int + if priority.Valid { + priorityVal = int(priority.Int64) + project.Priority = &priorityVal + } + + // Расчет calculated_score + totalScore := project.TotalScore + minGoalScoreVal := project.MinGoalScore + var maxGoalScoreVal float64 + if project.MaxGoalScore != nil { + maxGoalScoreVal = *project.MaxGoalScore + } + + // Параметры бонуса в зависимости от priority + var extraBonusLimit float64 = 20 + if priorityVal == 1 { + extraBonusLimit = 50 + } else if priorityVal == 2 { + extraBonusLimit = 35 + } + + // Расчет calculated_score по логике фронтенда + // min_goal -> 100%, max_goal -> 150%/135%/120% в зависимости от приоритета + var resultScore float64 + if minGoalScoreVal <= 0 { + // Если нет minGoal, возвращаем 0 (или можно относительно maxGoal, но обычно 0) + resultScore = 0 + } else if totalScore < minGoalScoreVal { + // До достижения minGoal растем линейно от 0 до 100% + resultScore = (totalScore / minGoalScoreVal) * 100.0 + } else { + // Достигнут minGoal - базовый прогресс = 100% + baseProgress := 100.0 + + // Если maxGoal задан корректно и больше minGoal, добавляем экстра прогресс + if maxGoalScoreVal > minGoalScoreVal { + extraRange := maxGoalScoreVal - minGoalScoreVal + excess := min(totalScore, maxGoalScoreVal) - minGoalScoreVal + extraRatio := min(1.0, max(0.0, excess/extraRange)) + extraProgress := extraRatio * extraBonusLimit + resultScore = min(100.0+extraBonusLimit, baseProgress+extraProgress) + } else { + // Если maxGoal не задан или некорректен, просто 100% + resultScore = baseProgress + } + } + + project.CalculatedScore = roundToTwoDecimals(resultScore) + projects = append(projects, project) + + // Группировка для итогового расчета + if minGoalScoreVal > 0 { + if _, exists := groups[priorityVal]; !exists { + groups[priorityVal] = make([]float64, 0) + } + groups[priorityVal] = append(groups[priorityVal], project.CalculatedScore) + } + } + + // Вычисляем проценты для каждой группы + groupsProgress := calculateGroupsProgress(groups) + + // Вычисляем общий процент выполнения + total := calculateOverallProgress(groupsProgress, groups) + + response := WeeklyStatsResponse{ + Total: total, + GroupProgress1: groupsProgress.Group1, + GroupProgress2: groupsProgress.Group2, + GroupProgress0: groupsProgress.Group0, + Projects: projects, + } + + return &response, nil +} + +// getISOWeek вычисляет номер недели ISO для даты +func getISOWeek(t time.Time) int { + _, week := t.ISOWeek() + return week +} + +// getTrackingStatsHandler возвращает статистику недели для текущего пользователя и отслеживаемых +func (a *App) getTrackingStatsHandler(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 + } + + // Получаем year и week из query params + yearStr := r.URL.Query().Get("year") + weekStr := r.URL.Query().Get("week") + + var year, week int + now := time.Now() + + if yearStr == "" || weekStr == "" { + // Если не указаны - текущая неделя + year, week = now.ISOWeek() + } else { + var err error + year, err = strconv.Atoi(yearStr) + if err != nil { + sendErrorWithCORS(w, "Invalid year parameter", http.StatusBadRequest) + return + } + week, err = strconv.Atoi(weekStr) + if err != nil { + sendErrorWithCORS(w, "Invalid week parameter", http.StatusBadRequest) + return + } + } + + // Получаем список отслеживаемых пользователей + rows, err := a.DB.Query(` + SELECT tracked_id + FROM user_tracking + WHERE tracker_id = $1 + `, userID) + if err != nil { + log.Printf("Error getting tracked users: %v", err) + sendErrorWithCORS(w, "Error getting tracked users", http.StatusInternalServerError) + return + } + defer rows.Close() + + trackedUserIDs := []int{userID} // Начинаем с текущего пользователя + for rows.Next() { + var trackedID int + if err := rows.Scan(&trackedID); err != nil { + log.Printf("Error scanning tracked user: %v", err) + continue + } + trackedUserIDs = append(trackedUserIDs, trackedID) + } + + // Получаем данные для каждого пользователя + users := make([]TrackingUserStats, 0) + for _, uid := range trackedUserIDs { + stats, err := a.getWeeklyStatsDataForUserAndWeek(uid, year, week) + if err != nil { + log.Printf("Error getting stats for user %d: %v", uid, err) + continue + } + + // Получаем имя пользователя + var userName string + err = a.DB.QueryRow(`SELECT COALESCE(name, email) FROM users WHERE id = $1`, uid).Scan(&userName) + if err != nil { + log.Printf("Error getting user name: %v", err) + userName = "Unknown" + } + + // Преобразуем проекты в TrackingProjectStats + projects := make([]TrackingProjectStats, 0) + for _, p := range stats.Projects { + projects = append(projects, TrackingProjectStats{ + ProjectName: p.ProjectName, + CalculatedScore: p.CalculatedScore, + Priority: p.Priority, + }) + } + + // Сортируем проекты по priority (1, 2, остальные) + sort.Slice(projects, func(i, j int) bool { + pi, pj := 99, 99 + if projects[i].Priority != nil { + pi = *projects[i].Priority + } + if projects[j].Priority != nil { + pj = *projects[j].Priority + } + return pi < pj + }) + + users = append(users, TrackingUserStats{ + UserID: uid, + UserName: userName, + IsCurrentUser: uid == userID, + Total: stats.Total, + Projects: projects, + }) + } + + // Сортируем: текущий пользователь всегда первый + sortedUsers := make([]TrackingUserStats, 0) + for _, u := range users { + if u.IsCurrentUser { + sortedUsers = append([]TrackingUserStats{u}, sortedUsers...) + } else { + sortedUsers = append(sortedUsers, u) + } + } + + response := TrackingStatsResponse{ + WeekNumber: week, + Year: year, + Users: sortedUsers, + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(response) +} + +// createTrackingInviteHandler создает токен приглашения +func (a *App) createTrackingInviteHandler(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 + } + + // Генерируем токен + token := generateInviteToken() + + // Сохраняем в базу с истечением через 1 час + _, err := a.DB.Exec(` + INSERT INTO tracking_invite_tokens (user_id, token, expires_at) + VALUES ($1, $2, NOW() + INTERVAL '1 hour') + `, userID, token) + if err != nil { + log.Printf("Error creating tracking invite token: %v", err) + sendErrorWithCORS(w, "Error creating invite token", http.StatusInternalServerError) + return + } + + // Формируем URL + baseURL := getEnv("WEBHOOK_BASE_URL", "") + inviteURL := baseURL + "/tracking/invite/" + token + + response := TrackingInviteResponse{ + InviteURL: inviteURL, + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(response) +} + +// getTrackingInviteInfoHandler возвращает информацию о приглашении +func (a *App) getTrackingInviteInfoHandler(w http.ResponseWriter, r *http.Request) { + if r.Method == "OPTIONS" { + setCORSHeaders(w) + w.WriteHeader(http.StatusOK) + return + } + setCORSHeaders(w) + + vars := mux.Vars(r) + token := vars["token"] + + var userID int + var expiresAt time.Time + err := a.DB.QueryRow(` + SELECT user_id, expires_at + FROM tracking_invite_tokens + WHERE token = $1 + `, token).Scan(&userID, &expiresAt) + + if err == sql.ErrNoRows { + sendErrorWithCORS(w, "Ссылка недействительна или устарела", http.StatusNotFound) + return + } + if err != nil { + log.Printf("Error getting invite token: %v", err) + sendErrorWithCORS(w, "Error getting invite info", http.StatusInternalServerError) + return + } + + // Проверяем срок действия + if time.Now().After(expiresAt) { + sendErrorWithCORS(w, "Ссылка недействительна или устарела", http.StatusNotFound) + return + } + + // Получаем имя пользователя + var userName string + err = a.DB.QueryRow(`SELECT COALESCE(name, email) FROM users WHERE id = $1`, userID).Scan(&userName) + if err != nil { + log.Printf("Error getting user name: %v", err) + sendErrorWithCORS(w, "Error getting user info", http.StatusInternalServerError) + return + } + + response := TrackingInviteInfo{ + UserID: userID, + UserName: userName, + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(response) +} + +// acceptTrackingInviteHandler принимает приглашение на отслеживание +func (a *App) acceptTrackingInviteHandler(w http.ResponseWriter, r *http.Request) { + if r.Method == "OPTIONS" { + setCORSHeaders(w) + w.WriteHeader(http.StatusOK) + return + } + setCORSHeaders(w) + + currentUserID, ok := getUserIDFromContext(r) + if !ok { + sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized) + return + } + + vars := mux.Vars(r) + token := vars["token"] + + // Получаем информацию о токене + var trackedUserID int + var expiresAt time.Time + err := a.DB.QueryRow(` + SELECT user_id, expires_at + FROM tracking_invite_tokens + WHERE token = $1 AND expires_at > NOW() + `, token).Scan(&trackedUserID, &expiresAt) + + if err == sql.ErrNoRows { + sendErrorWithCORS(w, "Ссылка недействительна или устарела", http.StatusNotFound) + return + } + if err != nil { + log.Printf("Error getting invite token: %v", err) + sendErrorWithCORS(w, "Error getting invite info", http.StatusInternalServerError) + return + } + + // Проверяем, что пользователь не пытается отслеживать себя + if trackedUserID == currentUserID { + sendErrorWithCORS(w, "Нельзя отслеживать себя", http.StatusBadRequest) + return + } + + // Создаем запись отслеживания + _, err = a.DB.Exec(` + INSERT INTO user_tracking (tracker_id, tracked_id) + VALUES ($1, $2) + ON CONFLICT (tracker_id, tracked_id) DO NOTHING + `, currentUserID, trackedUserID) + if err != nil { + log.Printf("Error creating tracking relation: %v", err) + sendErrorWithCORS(w, "Error accepting invite", http.StatusInternalServerError) + return + } + + // Удаляем использованный токен (одноразовый) + _, err = a.DB.Exec(`DELETE FROM tracking_invite_tokens WHERE token = $1`, token) + if err != nil { + log.Printf("Error deleting used token: %v", err) + // Не критично, продолжаем + } + + // Получаем имя пользователя для ответа + var userName string + a.DB.QueryRow(`SELECT COALESCE(name, email) FROM users WHERE id = $1`, trackedUserID).Scan(&userName) + + response := map[string]interface{}{ + "success": true, + "tracked_user_name": userName, + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(response) +} + +// getTrackingAccessHandler возвращает списки трекеров и отслеживаемых +func (a *App) getTrackingAccessHandler(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 + } + + // Trackers (кто меня отслеживает) + trackers := make([]TrackingUser, 0) + rows, err := a.DB.Query(` + SELECT ut.id as relation_id, u.id, COALESCE(u.name, u.email) as name, u.email, ut.created_at + FROM user_tracking ut + JOIN users u ON ut.tracker_id = u.id + WHERE ut.tracked_id = $1 + ORDER BY ut.created_at DESC + `, userID) + if err != nil { + log.Printf("Error getting trackers: %v", err) + sendErrorWithCORS(w, "Error getting trackers", http.StatusInternalServerError) + return + } + defer rows.Close() + + for rows.Next() { + var t TrackingUser + if err := rows.Scan(&t.RelationID, &t.ID, &t.Name, &t.Email, &t.CreatedAt); err != nil { + log.Printf("Error scanning tracker: %v", err) + continue + } + trackers = append(trackers, t) + } + + // Tracked (кого я отслеживаю) + tracked := make([]TrackingUser, 0) + rows, err = a.DB.Query(` + SELECT ut.id as relation_id, u.id, COALESCE(u.name, u.email) as name, u.email, ut.created_at + FROM user_tracking ut + JOIN users u ON ut.tracked_id = u.id + WHERE ut.tracker_id = $1 + ORDER BY ut.created_at DESC + `, userID) + if err != nil { + log.Printf("Error getting tracked: %v", err) + sendErrorWithCORS(w, "Error getting tracked", http.StatusInternalServerError) + return + } + defer rows.Close() + + for rows.Next() { + var t TrackingUser + if err := rows.Scan(&t.RelationID, &t.ID, &t.Name, &t.Email, &t.CreatedAt); err != nil { + log.Printf("Error scanning tracked: %v", err) + continue + } + tracked = append(tracked, t) + } + + response := TrackingAccessResponse{ + Trackers: trackers, + Tracked: tracked, + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(response) +} + +// deleteTrackingTrackerHandler удаляет того, кто меня отслеживает +func (a *App) deleteTrackingTrackerHandler(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 + } + + vars := mux.Vars(r) + relationIDStr := vars["id"] + relationID, err := strconv.Atoi(relationIDStr) + if err != nil { + sendErrorWithCORS(w, "Invalid relation ID", http.StatusBadRequest) + return + } + + // Удаляем только если это действительно тот, кто отслеживает меня + result, err := a.DB.Exec(` + DELETE FROM user_tracking + WHERE id = $1 AND tracked_id = $2 + `, relationID, userID) + if err != nil { + log.Printf("Error deleting tracker relation: %v", err) + sendErrorWithCORS(w, "Error deleting relation", http.StatusInternalServerError) + return + } + + rowsAffected, _ := result.RowsAffected() + if rowsAffected == 0 { + sendErrorWithCORS(w, "Relation not found or access denied", http.StatusNotFound) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{"success": true}) +} + +// deleteTrackingTrackedHandler прекращает отслеживать пользователя +func (a *App) deleteTrackingTrackedHandler(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 + } + + vars := mux.Vars(r) + relationIDStr := vars["id"] + relationID, err := strconv.Atoi(relationIDStr) + if err != nil { + sendErrorWithCORS(w, "Invalid relation ID", http.StatusBadRequest) + return + } + + // Удаляем только если это действительно тот, кого я отслеживаю + result, err := a.DB.Exec(` + DELETE FROM user_tracking + WHERE id = $1 AND tracker_id = $2 + `, relationID, userID) + if err != nil { + log.Printf("Error deleting tracked relation: %v", err) + sendErrorWithCORS(w, "Error deleting relation", http.StatusInternalServerError) + return + } + + rowsAffected, _ := result.RowsAffected() + if rowsAffected == 0 { + sendErrorWithCORS(w, "Relation not found or access denied", http.StatusNotFound) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{"success": true}) +} + // decodeHTMLEntities декодирует базовые HTML entities func decodeHTMLEntities(s string) string { replacements := map[string]string{ diff --git a/play-life-backend/migrations/000013_add_user_tracking.down.sql b/play-life-backend/migrations/000013_add_user_tracking.down.sql new file mode 100644 index 0000000..06ebad0 --- /dev/null +++ b/play-life-backend/migrations/000013_add_user_tracking.down.sql @@ -0,0 +1,2 @@ +DROP TABLE IF EXISTS tracking_invite_tokens; +DROP TABLE IF EXISTS user_tracking; diff --git a/play-life-backend/migrations/000013_add_user_tracking.up.sql b/play-life-backend/migrations/000013_add_user_tracking.up.sql new file mode 100644 index 0000000..f31db29 --- /dev/null +++ b/play-life-backend/migrations/000013_add_user_tracking.up.sql @@ -0,0 +1,24 @@ +-- Таблица отслеживания между пользователями +CREATE TABLE user_tracking ( + id SERIAL PRIMARY KEY, + tracker_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + tracked_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT unique_tracking_pair UNIQUE (tracker_id, tracked_id), + CONSTRAINT no_self_tracking CHECK (tracker_id != tracked_id) +); + +CREATE INDEX idx_user_tracking_tracker ON user_tracking(tracker_id); +CREATE INDEX idx_user_tracking_tracked ON user_tracking(tracked_id); + +-- Таблица токенов приглашений (живут 1 час) +CREATE TABLE tracking_invite_tokens ( + id SERIAL PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + token VARCHAR(64) NOT NULL UNIQUE, + expires_at TIMESTAMP WITH TIME ZONE NOT NULL, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_tracking_invite_tokens_token ON tracking_invite_tokens(token); +CREATE INDEX idx_tracking_invite_tokens_user ON tracking_invite_tokens(user_id); diff --git a/play-life-web/package.json b/play-life-web/package.json index de5e057..69a75c9 100644 --- a/play-life-web/package.json +++ b/play-life-web/package.json @@ -1,6 +1,6 @@ { "name": "play-life-web", - "version": "4.22.0", + "version": "4.23.0", "type": "module", "scripts": { "dev": "vite", diff --git a/play-life-web/src/App.jsx b/play-life-web/src/App.jsx index d9bdbac..0a013e1 100644 --- a/play-life-web/src/App.jsx +++ b/play-life-web/src/App.jsx @@ -16,6 +16,9 @@ import BoardForm from './components/BoardForm' import BoardJoinPreview from './components/BoardJoinPreview' import TodoistIntegration from './components/TodoistIntegration' import TelegramIntegration from './components/TelegramIntegration' +import Tracking from './components/Tracking' +import TrackingAccess from './components/TrackingAccess' +import TrackingInviteAccept from './components/TrackingInviteAccept' import { AuthProvider, useAuth } from './components/auth/AuthContext' import AuthScreen from './components/auth/AuthScreen' import PWAUpdatePrompt from './components/PWAUpdatePrompt' @@ -26,7 +29,7 @@ const FULL_STATISTICS_API_URL = '/d2dc349a-0d13-49b2-a8f0-1ab094bfba9b' // Определяем основные табы (без крестика) и глубокие табы (с крестиком) const mainTabs = ['current', 'tasks', 'wishlist', 'profile'] -const deepTabs = ['add-words', 'test', 'task-form', 'wishlist-form', 'wishlist-detail', 'board-form', 'board-join', 'words', 'dictionaries', 'todoist-integration', 'telegram-integration', 'full', 'priorities'] +const deepTabs = ['add-words', 'test', 'task-form', 'wishlist-form', 'wishlist-detail', 'board-form', 'board-join', 'words', 'dictionaries', 'todoist-integration', 'telegram-integration', 'full', 'priorities', 'tracking', 'tracking-access', 'tracking-invite'] function AppContent() { const { authFetch, isAuthenticated, loading: authLoading } = useAuth() @@ -64,6 +67,9 @@ function AppContent() { profile: false, 'todoist-integration': false, 'telegram-integration': false, + tracking: false, + 'tracking-access': false, + 'tracking-invite': false, }) // Отслеживаем, какие табы уже были загружены (для предотвращения повторных загрузок) @@ -85,6 +91,9 @@ function AppContent() { profile: false, 'todoist-integration': false, 'telegram-integration': false, + tracking: false, + 'tracking-access': false, + 'tracking-invite': false, }) // Параметры для навигации между вкладками @@ -155,10 +164,23 @@ function AppContent() { } } + // Проверяем путь /tracking/invite/:token + if (path.startsWith('/tracking/invite/')) { + const token = path.replace('/tracking/invite/', '') + if (token) { + setActiveTab('tracking-invite') + setLoadedTabs(prev => ({ ...prev, 'tracking-invite': true })) + setTabParams({ inviteToken: token }) + setIsInitialized(true) + window.history.replaceState({}, '', '/?tab=tracking-invite&inviteToken=' + token) + return + } + } + // Проверяем URL только для глубоких табов const urlParams = new URLSearchParams(window.location.search) const tabFromUrl = urlParams.get('tab') - const validTabs = ['current', 'priorities', 'full', 'words', 'add-words', 'dictionaries', 'test', 'tasks', 'task-form', 'wishlist', 'wishlist-form', 'wishlist-detail', 'board-form', 'board-join', 'profile', 'todoist-integration', 'telegram-integration'] + const validTabs = ['current', 'priorities', 'full', 'words', 'add-words', 'dictionaries', 'test', 'tasks', 'task-form', 'wishlist', 'wishlist-form', 'wishlist-detail', 'board-form', 'board-join', 'profile', 'todoist-integration', 'telegram-integration', 'tracking', 'tracking-access', 'tracking-invite'] if (tabFromUrl && validTabs.includes(tabFromUrl) && deepTabs.includes(tabFromUrl)) { // Если в URL есть глубокий таб, восстанавливаем его @@ -469,6 +491,9 @@ function AppContent() { profile: false, 'todoist-integration': false, 'telegram-integration': false, + tracking: false, + 'tracking-access': false, + 'tracking-invite': false, }) // Используем ref для отслеживания кеша (чтобы не зависеть от состояния в useCallback) @@ -618,7 +643,7 @@ function AppContent() { return } - const validTabs = ['current', 'priorities', 'full', 'words', 'add-words', 'dictionaries', 'test', 'tasks', 'task-form', 'wishlist', 'wishlist-form', 'wishlist-detail', 'profile', 'todoist-integration', 'telegram-integration'] + const validTabs = ['current', 'priorities', 'full', 'words', 'add-words', 'dictionaries', 'test', 'tasks', 'task-form', 'wishlist', 'wishlist-form', 'wishlist-detail', 'profile', 'todoist-integration', 'telegram-integration', 'tracking', 'tracking-access', 'tracking-invite'] // Проверяем state текущей записи истории (куда мы вернулись) if (event.state && event.state.tab) { @@ -915,7 +940,7 @@ function AppContent() { }, [activeTab]) // Определяем, нужно ли скрывать нижнюю панель (для fullscreen экранов) - const isFullscreenTab = activeTab === 'test' || activeTab === 'add-words' || activeTab === 'task-form' || activeTab === 'wishlist-form' || activeTab === 'wishlist-detail' || activeTab === 'todoist-integration' || activeTab === 'telegram-integration' || activeTab === 'full' || activeTab === 'priorities' || activeTab === 'words' || activeTab === 'dictionaries' + const isFullscreenTab = activeTab === 'test' || activeTab === 'add-words' || activeTab === 'task-form' || activeTab === 'wishlist-form' || activeTab === 'wishlist-detail' || activeTab === 'todoist-integration' || activeTab === 'telegram-integration' || activeTab === 'full' || activeTab === 'priorities' || activeTab === 'words' || activeTab === 'dictionaries' || activeTab === 'tracking' || activeTab === 'tracking-access' || activeTab === 'tracking-invite' // Функция для получения классов скролл-контейнера для каждого таба // Каждый таб имеет свой изолированный скролл-контейнер для автоматического сохранения позиции скролла @@ -1173,6 +1198,33 @@ function AppContent() { )} + + {loadedTabs.tracking && ( +
+
+ +
+
+ )} + + {loadedTabs['tracking-access'] && ( +
+
+ +
+
+ )} + + {loadedTabs['tracking-invite'] && ( +
+
+ +
+
+ )} {/* Кнопка добавления задачи (только для таба задач) */} diff --git a/play-life-web/src/components/Profile.jsx b/play-life-web/src/components/Profile.jsx index d7c9fdc..3634a3a 100644 --- a/play-life-web/src/components/Profile.jsx +++ b/play-life-web/src/components/Profile.jsx @@ -35,22 +35,47 @@ function Profile({ onNavigate }) { - {/* Admin Button */} - {user?.is_admin && ( -
+ {/* Admin & Tracking Buttons */} +
+
+ {user?.is_admin && ( + + )}
- )} +
{/* Features Section */}
diff --git a/play-life-web/src/components/Tracking.css b/play-life-web/src/components/Tracking.css new file mode 100644 index 0000000..9372f59 --- /dev/null +++ b/play-life-web/src/components/Tracking.css @@ -0,0 +1,435 @@ +/* ===== Tracking Screen ===== */ +.tracking-screen { + padding: 0 1rem 1rem 1rem; +} + +/* Header with title and close button */ +.tracking-header { + position: relative; + margin-bottom: 1.5rem; + padding-top: 1.25rem; +} + +.tracking-header h2 { + margin-top: 0; + margin-bottom: 0; +} + +.close-x-button { + position: fixed; + top: 1rem; + right: 1rem; + background: rgba(255, 255, 255, 0.9); + border: none; + font-size: 1.5rem; + color: #7f8c8d; + cursor: pointer; + width: 40px; + height: 40px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 50%; + transition: background-color 0.2s, color 0.2s; + z-index: 1600; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); +} + +.close-x-button:hover { + background-color: #ffffff; + color: #2c3e50; +} + +/* Week controls: scrollable chips + access button */ +.week-controls { + display: flex; + align-items: center; + gap: 0.75rem; + margin-bottom: 0.75rem; +} + +.week-chips-scroll { + display: flex; + flex-wrap: nowrap; + gap: 0.625rem; + overflow-x: auto; + scrollbar-width: none; + -ms-overflow-style: none; + flex: 1; + min-width: 0; +} + +.week-chips-scroll::-webkit-scrollbar { + display: none; +} + +.access-icon-btn { + flex-shrink: 0; + width: 2.25rem; + height: 2.25rem; + display: flex; + align-items: center; + justify-content: center; + border-radius: 0.5rem; + border: 1px solid #d1d5db; + background: white; + color: #374151; + cursor: pointer; + transition: all 0.2s ease; +} + +.access-icon-btn:hover { + border-color: #a5b4fc; + color: #4f46e5; + background: #f5f3ff; +} + +/* Week chips */ +.week-chips { + display: flex; + flex-wrap: wrap; + gap: 0.625rem; + margin-bottom: 1.5rem; +} + +.week-chip { + height: 2.25rem; + padding: 0 1rem; + border-radius: 0.5rem; + font-size: 0.875rem; + font-weight: 600; + transition: all 0.2s ease; + background: transparent; + color: #374151; + border: 1px solid #d1d5db; + cursor: pointer; + white-space: nowrap; + flex-shrink: 0; +} + +.week-chip:hover { + border-color: #9ca3af; +} + +.week-chip.selected { + background: white; + color: #111827; + box-shadow: 0 1px 2px rgba(0,0,0,0.05); + border-color: #e5e7eb; +} + +.week-chip.current:not(.selected) { + border-color: #a5b4fc; + box-shadow: 0 0 0 2px rgba(165, 180, 252, 0.3); +} + +/* User tracking cards */ +.users-list { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.user-tracking-card { + background: white; + border-radius: 1.5rem; + padding: 1.125rem 1.5rem; + border: none; +} + +.user-tracking-card.current-user { + border: 1px solid #a5b4fc; + background: white; +} + +.user-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 0.5rem; + padding-bottom: 0.5rem; + border-bottom: 1px solid #d1d5db; +} + +.user-name { + font-size: 1.5rem; + font-weight: 600; + color: #111827; +} + +.user-total { + font-size: 1.5rem; + font-weight: 700; +} + +.percent-green { + color: #10b981; +} + +.percent-blue { + color: #3b82f6; +} + +.projects-list { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.project-row { + display: flex; + justify-content: space-between; + font-size: 1rem; +} + +.project-name { + color: #374151; +} + +.project-score { + font-weight: 500; +} + +/* Access link section */ +.access-link-section { + margin-top: 2rem; + padding-top: 1.5rem; + border-top: 1px solid #e5e7eb; +} + +.access-link-button { + width: 100%; + display: flex; + justify-content: space-between; + align-items: center; + padding: 1rem; + background: white; + border: 1px solid #e5e7eb; + border-radius: 0.75rem; + color: #374151; + font-weight: 500; + cursor: pointer; + transition: all 0.2s; +} + +.access-link-button:hover { + border-color: #a5b4fc; + color: #4f46e5; +} + +/* ===== Tracking Access Screen ===== */ +.tracking-access-screen { + padding: 0 1rem 1rem 1rem; +} + +.screen-title { + font-size: 1.25rem; + font-weight: 600; + margin-bottom: 1.5rem; +} + +.access-section { + background: white; + border-radius: 0.75rem; + padding: 1rem; + margin-bottom: 1rem; + border: 1px solid #e5e7eb; +} + +.access-section h3 { + font-size: 1rem; + font-weight: 600; + margin-bottom: 0.5rem; +} + +.section-hint { + font-size: 0.875rem; + color: #6b7280; + margin-bottom: 0.75rem; +} + +.create-invite-btn { + width: 100%; + padding: 0.75rem 1rem; + background: linear-gradient(to right, #4f46e5, #7c3aed); + color: white; + border: none; + border-radius: 0.5rem; + font-weight: 500; + cursor: pointer; + transition: all 0.2s; +} + +.create-invite-btn:hover:not(:disabled) { + opacity: 0.9; +} + +.create-invite-btn:disabled { + opacity: 0.7; + cursor: not-allowed; +} + +.create-invite-btn.copied { + background: #10b981; +} + +.empty-list { + color: #9ca3af; + font-size: 0.875rem; + padding: 0.5rem 0; +} + +.access-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.75rem 0; + border-bottom: 1px solid #f3f4f6; +} + +.access-item:last-child { + border-bottom: none; +} + +.access-item-name { + font-weight: 500; +} + +.remove-btn { + padding: 0.5rem; + color: #9ca3af; + background: none; + border: none; + cursor: pointer; + border-radius: 0.375rem; + transition: all 0.2s; +} + +.remove-btn:hover { + color: #ef4444; + background: #fef2f2; +} + +/* ===== Tracking Invite Screen ===== */ +.tracking-invite-screen { + min-height: 100vh; + display: flex; + align-items: center; + justify-content: center; + padding: 1rem; +} + +.invite-card { + background: white; + border-radius: 1rem; + padding: 2rem; + max-width: 24rem; + width: 100%; + text-align: center; + box-shadow: 0 4px 6px -1px rgba(0,0,0,0.1); +} + +.invite-card.error-card { + border: 1px solid #fecaca; +} + +.invite-icon { + font-size: 3rem; + margin-bottom: 1rem; +} + +.invite-card h2 { + font-size: 1.25rem; + font-weight: 600; + margin-bottom: 1rem; +} + +.invite-info { + margin-bottom: 1.5rem; +} + +.user-name-large { + font-size: 1.125rem; + font-weight: 600; + color: #4f46e5; + margin-top: 0.5rem; +} + +.accept-btn { + width: 100%; + padding: 0.875rem; + background: linear-gradient(to right, #4f46e5, #7c3aed); + color: white; + border: none; + border-radius: 0.5rem; + font-weight: 500; + cursor: pointer; + margin-bottom: 1rem; +} + +.accept-btn:disabled { + opacity: 0.7; +} + +.cancel-link { + background: none; + border: none; + color: #6b7280; + cursor: pointer; + font-size: 0.875rem; +} + +.cancel-link:hover { + color: #374151; +} + +.error-text { + color: #ef4444; + margin-bottom: 1rem; +} + +.error-inline { + color: #ef4444; + font-size: 0.875rem; + margin-bottom: 1rem; +} + +.secondary-btn { + padding: 0.75rem 1.5rem; + background: #f3f4f6; + color: #374151; + border: none; + border-radius: 0.5rem; + cursor: pointer; +} + +/* Loading */ +.loading-container { + text-align: center; +} + +.spinner { + width: 3rem; + height: 3rem; + border: 4px solid #e0e7ff; + border-top-color: #4f46e5; + border-radius: 50%; + animation: spin 1s linear infinite; + margin: 0 auto 1rem; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +.loading-spinner { + text-align: center; + padding: 2rem; + color: #6b7280; +} + +.error-message { + text-align: center; + padding: 2rem; + color: #ef4444; +} diff --git a/play-life-web/src/components/Tracking.jsx b/play-life-web/src/components/Tracking.jsx new file mode 100644 index 0000000..ef88f01 --- /dev/null +++ b/play-life-web/src/components/Tracking.jsx @@ -0,0 +1,184 @@ +import React, { useState, useEffect, useMemo, useRef } from 'react' +import { useAuth } from './auth/AuthContext' +import './Tracking.css' + +// Функция для вычисления номера недели ISO +function getISOWeek(date) { + const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate())) + const dayNum = d.getUTCDay() || 7 + d.setUTCDate(d.getUTCDate() + 4 - dayNum) + const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1)) + return Math.ceil((((d - yearStart) / 86400000) + 1) / 7) +} + +// Функция для вычисления 5 недель (текущая + 4 предыдущие) +function getLastFiveWeeks() { + const weeks = [] + const now = new Date() + + for (let i = 0; i < 5; i++) { + const date = new Date(now) + date.setDate(date.getDate() - i * 7) + const week = getISOWeek(date) + const year = date.getFullYear() + weeks.push({ + year, + week, + isCurrent: i === 0 + }) + } + return weeks.reverse() // От старой к новой +} + +function Tracking({ onNavigate, activeTab }) { + const { authFetch } = useAuth() + const weeks = useMemo(() => getLastFiveWeeks(), []) + const [selectedWeek, setSelectedWeek] = useState(weeks[weeks.length - 1]) // Текущая неделя + const [data, setData] = useState(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const scrollContainerRef = useRef(null) + const currentWeekChipRef = useRef(null) + const prevActiveTabRef = useRef(null) + + // Сброс выбранной недели на текущую при открытии экрана + useEffect(() => { + // Проверяем, что экран только что открылся (activeTab стал 'tracking') + if (activeTab === 'tracking' && prevActiveTabRef.current !== 'tracking') { + const currentWeek = weeks[weeks.length - 1] // Последняя неделя в списке - текущая + setSelectedWeek(currentWeek) + } + prevActiveTabRef.current = activeTab + }, [activeTab, weeks]) + + // Скролл к чипсу текущей недели при открытии экрана + useEffect(() => { + // Выполняем скролл только когда экран открыт и только что открылся + if (activeTab === 'tracking' && currentWeekChipRef.current && scrollContainerRef.current) { + const chip = currentWeekChipRef.current + const container = scrollContainerRef.current + + // Небольшая задержка для гарантии рендеринга + setTimeout(() => { + const chipLeft = chip.offsetLeft + const chipWidth = chip.offsetWidth + const containerWidth = container.offsetWidth + const scrollLeft = chipLeft - (containerWidth / 2) + (chipWidth / 2) + + container.scrollTo({ + left: scrollLeft, + behavior: 'smooth' + }) + }, 100) + } + }, [activeTab]) + + // Загрузка данных при смене недели + useEffect(() => { + const fetchData = async () => { + setLoading(true) + setError(null) + try { + const res = await authFetch(`/api/tracking/stats?year=${selectedWeek.year}&week=${selectedWeek.week}`) + if (res.ok) { + setData(await res.json()) + } else { + setError('Ошибка загрузки') + } + } catch (err) { + setError('Ошибка загрузки') + } finally { + setLoading(false) + } + } + fetchData() + }, [selectedWeek, authFetch]) + + return ( +
+ {/* Заголовок с крестиком */} +
+

Отслеживание

+
+ + + {/* Чипсы недель с кнопкой доступов */} +
+
+ {weeks.map(w => ( + + ))} +
+ +
+ + {/* Контент */} + {loading ? ( +
Загрузка...
+ ) : error ? ( +
{error}
+ ) : ( +
+ {data?.users.map(user => ( + + ))} +
+ )} +
+ ) +} + +// Карточка пользователя с прогрессом +function UserTrackingCard({ user }) { + // Сортируем проекты по priority (1, 2, остальные) + const sortedProjects = [...user.projects].sort((a, b) => { + const pa = a.priority ?? 99 + const pb = b.priority ?? 99 + return pa - pb + }) + + const totalPercent = user.total || 0 + const getPercentColorClass = (percent) => { + return percent >= 100 ? 'percent-green' : 'percent-blue' + } + + return ( +
+
+ {user.user_name} + {totalPercent.toFixed(0)}% +
+
+ {sortedProjects.map((project, idx) => { + const projectPercent = project.calculated_score || 0 + return ( +
+ {project.project_name} + {projectPercent.toFixed(0)}% +
+ ) + })} +
+
+ ) +} + +export default Tracking diff --git a/play-life-web/src/components/TrackingAccess.jsx b/play-life-web/src/components/TrackingAccess.jsx new file mode 100644 index 0000000..d35f27b --- /dev/null +++ b/play-life-web/src/components/TrackingAccess.jsx @@ -0,0 +1,148 @@ +import React, { useState, useEffect } from 'react' +import { useAuth } from './auth/AuthContext' +import Toast from './Toast' +import './Tracking.css' + +function TrackingAccess({ onNavigate }) { + const { authFetch } = useAuth() + const [generating, setGenerating] = useState(false) + const [copied, setCopied] = useState(false) + const [trackers, setTrackers] = useState([]) + const [tracked, setTracked] = useState([]) + const [loading, setLoading] = useState(true) + const [toastMessage, setToastMessage] = useState(null) + + // Загрузка списков при монтировании + useEffect(() => { + fetchAccessData() + }, []) + + const fetchAccessData = async () => { + setLoading(true) + try { + const res = await authFetch('/api/tracking/access') + if (res.ok) { + const data = await res.json() + setTrackers(data.trackers || []) + setTracked(data.tracked || []) + } + } catch (err) { + console.error('Error fetching access data:', err) + } finally { + setLoading(false) + } + } + + const handleCreateInvite = async () => { + setGenerating(true) + try { + const res = await authFetch('/api/tracking/invite', { method: 'POST' }) + if (res.ok) { + const data = await res.json() + await navigator.clipboard.writeText(data.invite_url) + setCopied(true) + setToastMessage({ text: 'Ссылка скопирована! Действует 1 час', type: 'success' }) + setTimeout(() => setCopied(false), 3000) + } else { + setToastMessage({ text: 'Ошибка создания ссылки', type: 'error' }) + } + } catch (err) { + setToastMessage({ text: 'Ошибка создания ссылки', type: 'error' }) + } finally { + setGenerating(false) + } + } + + const handleRemoveTracker = async (relationId) => { + if (!window.confirm('Запретить этому пользователю видеть вашу статистику?')) return + try { + const res = await authFetch(`/api/tracking/trackers/${relationId}`, { method: 'DELETE' }) + if (res.ok) { + setTrackers(prev => prev.filter(t => t.relation_id !== relationId)) + setToastMessage({ text: 'Доступ отозван', type: 'success' }) + } + } catch (err) { + setToastMessage({ text: 'Ошибка', type: 'error' }) + } + } + + const handleRemoveTracked = async (relationId) => { + if (!window.confirm('Прекратить отслеживать этого пользователя?')) return + try { + const res = await authFetch(`/api/tracking/tracked/${relationId}`, { method: 'DELETE' }) + if (res.ok) { + setTracked(prev => prev.filter(t => t.relation_id !== relationId)) + setToastMessage({ text: 'Отслеживание прекращено', type: 'success' }) + } + } catch (err) { + setToastMessage({ text: 'Ошибка', type: 'error' }) + } + } + + return ( +
+ {/* Заголовок с крестиком */} +
+

Управление доступами

+
+ + + {/* Секция создания ссылки */} +
+

Поделиться статистикой

+

Создайте одноразовую ссылку (действует 1 час)

+ +
+ + {/* Список: кто меня отслеживает */} +
+

Меня отслеживают ({trackers.length})

+ {trackers.length === 0 ? ( +

Пока никто не отслеживает вашу статистику

+ ) : ( + trackers.map(t => ( +
+ {t.name} + +
+ )) + )} +
+ + {/* Список: кого я отслеживаю */} +
+

Я отслеживаю ({tracked.length})

+ {tracked.length === 0 ? ( +

Вы пока никого не отслеживаете

+ ) : ( + tracked.map(t => ( +
+ {t.name} + +
+ )) + )} +
+ + {toastMessage && ( + setToastMessage(null)} /> + )} +
+ ) +} + +export default TrackingAccess diff --git a/play-life-web/src/components/TrackingInviteAccept.jsx b/play-life-web/src/components/TrackingInviteAccept.jsx new file mode 100644 index 0000000..c45d067 --- /dev/null +++ b/play-life-web/src/components/TrackingInviteAccept.jsx @@ -0,0 +1,115 @@ +import React, { useState, useEffect } from 'react' +import { useAuth } from './auth/AuthContext' +import './Tracking.css' + +function TrackingInviteAccept({ inviteToken, onNavigate }) { + const { authFetch } = useAuth() + const [inviteInfo, setInviteInfo] = useState(null) + const [loading, setLoading] = useState(true) + const [accepting, setAccepting] = useState(false) + const [error, setError] = useState(null) + + // Загрузить информацию о приглашении + useEffect(() => { + const fetchInviteInfo = async () => { + try { + const res = await authFetch(`/api/tracking/invite/${inviteToken}`) + if (res.ok) { + setInviteInfo(await res.json()) + } else { + const err = await res.json() + setError(err.error || 'Ссылка недействительна или устарела') + } + } catch (err) { + setError('Ошибка загрузки') + } finally { + setLoading(false) + } + } + + if (inviteToken) { + fetchInviteInfo() + } + }, [inviteToken, authFetch]) + + const handleAccept = async () => { + setAccepting(true) + setError(null) + try { + const res = await authFetch(`/api/tracking/invite/${inviteToken}/accept`, { method: 'POST' }) + if (res.ok) { + // Успех - переходим на экран отслеживания + onNavigate('tracking') + } else { + const err = await res.json() + setError(err.error || 'Ошибка при принятии приглашения') + } + } catch (err) { + setError('Ошибка при принятии приглашения') + } finally { + setAccepting(false) + } + } + + const handleClose = () => { + onNavigate('tracking') + } + + if (loading) { + return ( +
+
+
+

Загрузка...

+
+
+ ) + } + + if (error && !inviteInfo) { + return ( +
+
+
+

Ошибка

+

{error}

+ +
+
+ ) + } + + return ( +
+ + +
+
👁
+

Приглашение на отслеживание

+ +
+

Вы сможете видеть статистику пользователя:

+

{inviteInfo?.user_name}

+
+ + {error &&

{error}

} + + + + +
+
+ ) +} + +export default TrackingInviteAccept