4.23.0: Добавлено отслеживание и улучшен UI
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m31s

This commit is contained in:
poignatov
2026-02-05 18:36:14 +03:00
parent d6d40f4f86
commit 6e9e2db23e
12 changed files with 1817 additions and 104 deletions

View File

@@ -1 +1 @@
4.22.0 4.23.0

View File

@@ -14,6 +14,8 @@ services:
POSTGRES_DB: ${DB_NAME:-playeng} POSTGRES_DB: ${DB_NAME:-playeng}
ports: ports:
- "${DB_PORT:-5432}:5432" - "${DB_PORT:-5432}:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck: healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-playeng}"] test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-playeng}"]
interval: 10s interval: 10s
@@ -59,6 +61,10 @@ services:
env_file: env_file:
- .env - .env
volumes:
postgres_data:
name: play-life_postgres_data
networks: networks:
default: default:
name: play-life-network name: play-life-network

View File

@@ -270,6 +270,49 @@ type TelegramUpdate struct {
EditedMessage *TelegramMessage `json:"edited_message,omitempty"` 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 // Task structures
type Task struct { type Task struct {
ID int `json:"id"` ID int `json:"id"`
@@ -2698,28 +2741,39 @@ func (a *App) getWeeklyStatsHandler(w http.ResponseWriter, r *http.Request) {
} }
// Параметры бонуса в зависимости от priority // Параметры бонуса в зависимости от priority
var extraBonusLimit float64 = 40 var extraBonusLimit float64 = 20
if priorityVal == 1 { if priorityVal == 1 {
extraBonusLimit = 100 extraBonusLimit = 50
} else if priorityVal == 2 { } else if priorityVal == 2 {
extraBonusLimit = 70 extraBonusLimit = 35
} }
// Расчет базового прогресса // Расчет calculated_score по логике фронтенда
var baseProgress float64 // min_goal -> 100%, max_goal -> 150%/135%/120% в зависимости от приоритета
if minGoalScoreVal > 0 { var resultScore float64
baseProgress = (min(totalScore, minGoalScoreVal) / minGoalScoreVal) * 100.0 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, добавляем экстра прогресс
var extraProgress float64 if maxGoalScoreVal > minGoalScoreVal {
denominator := maxGoalScoreVal - minGoalScoreVal extraRange := maxGoalScoreVal - minGoalScoreVal
if denominator > 0 && totalScore > minGoalScoreVal {
excess := min(totalScore, maxGoalScoreVal) - minGoalScoreVal excess := min(totalScore, maxGoalScoreVal) - minGoalScoreVal
extraProgress = (excess / denominator) * extraBonusLimit extraRatio := min(1.0, max(0.0, excess/extraRange))
extraProgress := extraRatio * extraBonusLimit
resultScore = min(100.0+extraBonusLimit, baseProgress+extraProgress)
} else {
// Если maxGoal не задан или некорректен, просто 100%
resultScore = baseProgress
}
} }
resultScore := baseProgress + extraProgress
project.CalculatedScore = roundToTwoDecimals(resultScore) project.CalculatedScore = roundToTwoDecimals(resultScore)
// Группировка для итогового расчета // Группировка для итогового расчета
@@ -2738,7 +2792,7 @@ func (a *App) getWeeklyStatsHandler(w http.ResponseWriter, r *http.Request) {
groupsProgress := calculateGroupsProgress(groups) groupsProgress := calculateGroupsProgress(groups)
// Вычисляем общий процент выполнения // Вычисляем общий процент выполнения
total := calculateOverallProgress(groupsProgress) total := calculateOverallProgress(groupsProgress, groups)
response := WeeklyStatsResponse{ response := WeeklyStatsResponse{
Total: total, Total: total,
@@ -3232,28 +3286,39 @@ func (a *App) getWeeklyStatsData() (*WeeklyStatsResponse, error) {
} }
// Параметры бонуса в зависимости от priority // Параметры бонуса в зависимости от priority
var extraBonusLimit float64 = 40 var extraBonusLimit float64 = 20
if priorityVal == 1 { if priorityVal == 1 {
extraBonusLimit = 100 extraBonusLimit = 50
} else if priorityVal == 2 { } else if priorityVal == 2 {
extraBonusLimit = 70 extraBonusLimit = 35
} }
// Расчет базового прогресса // Расчет calculated_score по логике фронтенда
var baseProgress float64 // min_goal -> 100%, max_goal -> 150%/135%/120% в зависимости от приоритета
if minGoalScoreVal > 0 { var resultScore float64
baseProgress = (min(totalScore, minGoalScoreVal) / minGoalScoreVal) * 100.0 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, добавляем экстра прогресс
var extraProgress float64 if maxGoalScoreVal > minGoalScoreVal {
denominator := maxGoalScoreVal - minGoalScoreVal extraRange := maxGoalScoreVal - minGoalScoreVal
if denominator > 0 && totalScore > minGoalScoreVal {
excess := min(totalScore, maxGoalScoreVal) - minGoalScoreVal excess := min(totalScore, maxGoalScoreVal) - minGoalScoreVal
extraProgress = (excess / denominator) * extraBonusLimit extraRatio := min(1.0, max(0.0, excess/extraRange))
extraProgress := extraRatio * extraBonusLimit
resultScore = min(100.0+extraBonusLimit, baseProgress+extraProgress)
} else {
// Если maxGoal не задан или некорректен, просто 100%
resultScore = baseProgress
}
} }
resultScore := baseProgress + extraProgress
project.CalculatedScore = roundToTwoDecimals(resultScore) project.CalculatedScore = roundToTwoDecimals(resultScore)
// Группировка для итогового расчета // Группировка для итогового расчета
@@ -3272,7 +3337,7 @@ func (a *App) getWeeklyStatsData() (*WeeklyStatsResponse, error) {
groupsProgress := calculateGroupsProgress(groups) groupsProgress := calculateGroupsProgress(groups)
// Вычисляем общий процент выполнения // Вычисляем общий процент выполнения
total := calculateOverallProgress(groupsProgress) total := calculateOverallProgress(groupsProgress, groups)
response := WeeklyStatsResponse{ response := WeeklyStatsResponse{
Total: total, Total: total,
@@ -3392,28 +3457,39 @@ func (a *App) getWeeklyStatsDataForUser(userID int) (*WeeklyStatsResponse, error
} }
// Параметры бонуса в зависимости от priority // Параметры бонуса в зависимости от priority
var extraBonusLimit float64 = 40 var extraBonusLimit float64 = 20
if priorityVal == 1 { if priorityVal == 1 {
extraBonusLimit = 100 extraBonusLimit = 50
} else if priorityVal == 2 { } else if priorityVal == 2 {
extraBonusLimit = 70 extraBonusLimit = 35
} }
// Расчет базового прогресса // Расчет calculated_score по логике фронтенда
var baseProgress float64 // min_goal -> 100%, max_goal -> 150%/135%/120% в зависимости от приоритета
if minGoalScoreVal > 0 { var resultScore float64
baseProgress = (min(totalScore, minGoalScoreVal) / minGoalScoreVal) * 100.0 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, добавляем экстра прогресс
var extraProgress float64 if maxGoalScoreVal > minGoalScoreVal {
denominator := maxGoalScoreVal - minGoalScoreVal extraRange := maxGoalScoreVal - minGoalScoreVal
if denominator > 0 && totalScore > minGoalScoreVal {
excess := min(totalScore, maxGoalScoreVal) - minGoalScoreVal excess := min(totalScore, maxGoalScoreVal) - minGoalScoreVal
extraProgress = (excess / denominator) * extraBonusLimit extraRatio := min(1.0, max(0.0, excess/extraRange))
extraProgress := extraRatio * extraBonusLimit
resultScore = min(100.0+extraBonusLimit, baseProgress+extraProgress)
} else {
// Если maxGoal не задан или некорректен, просто 100%
resultScore = baseProgress
}
} }
resultScore := baseProgress + extraProgress
project.CalculatedScore = roundToTwoDecimals(resultScore) project.CalculatedScore = roundToTwoDecimals(resultScore)
projects = append(projects, project) projects = append(projects, project)
@@ -3431,7 +3507,7 @@ func (a *App) getWeeklyStatsDataForUser(userID int) (*WeeklyStatsResponse, error
groupsProgress := calculateGroupsProgress(groups) groupsProgress := calculateGroupsProgress(groups)
// Вычисляем общий процент выполнения // Вычисляем общий процент выполнения
total := calculateOverallProgress(groupsProgress) total := calculateOverallProgress(groupsProgress, groups)
response := WeeklyStatsResponse{ response := WeeklyStatsResponse{
Total: total, 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}", app.getBoardInviteInfoHandler).Methods("GET", "OPTIONS")
protected.HandleFunc("/api/wishlist/invite/{token}/join", app.joinBoardHandler).Methods("POST", "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") // Wishlist items (после boards, чтобы {id} не перехватывал "boards")
protected.HandleFunc("/api/wishlist/{id}", app.getWishlistItemHandler).Methods("GET", "OPTIONS") protected.HandleFunc("/api/wishlist/{id}", app.getWishlistItemHandler).Methods("GET", "OPTIONS")
protected.HandleFunc("/api/wishlist/{id}", app.updateWishlistHandler).Methods("PUT", "OPTIONS") protected.HandleFunc("/api/wishlist/{id}", app.updateWishlistHandler).Methods("PUT", "OPTIONS")
@@ -4091,6 +4176,7 @@ func roundToFourDecimals(val float64) float64 {
// groups - карта приоритетов к спискам calculatedScore проектов // groups - карта приоритетов к спискам calculatedScore проектов
// Возвращает структуру GroupsProgress с процентами для каждой группы // Возвращает структуру GroupsProgress с процентами для каждой группы
// Если какая-то группа отсутствует, она считается как 100% // Если какая-то группа отсутствует, она считается как 100%
// min_goal = 100%, max_goal = 150%/135%/120% в зависимости от приоритета
func calculateGroupsProgress(groups map[int][]float64) GroupsProgress { func calculateGroupsProgress(groups map[int][]float64) GroupsProgress {
// Всего есть 3 группы: приоритет 1, приоритет 2, приоритет 0 // Всего есть 3 группы: приоритет 1, приоритет 2, приоритет 0
// Вычисляем среднее для каждой группы, если она есть // Вычисляем среднее для каждой группы, если она есть
@@ -4109,34 +4195,22 @@ func calculateGroupsProgress(groups map[int][]float64) GroupsProgress {
// Если группы нет, считаем как 100% // Если группы нет, считаем как 100%
avg = 100.0 avg = 100.0
} else { } else {
// Вычисляем среднее для группы // Для приоритета 1 и 2 - обычное среднее
// Для всех приоритетов: если 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 - обычное среднее с корректировкой
if priorityVal == 1 || priorityVal == 2 { if priorityVal == 1 || priorityVal == 2 {
sum := 0.0 sum := 0.0
for _, score := range scores { for _, score := range scores {
sum += adjustScore(score) sum += score
} }
avg = sum / float64(len(scores)) avg = sum / float64(len(scores))
} else { } else {
// Для проектов без приоритета (priorityVal == 0) - специальная формула с корректировкой // Для проектов без приоритета (priorityVal == 0) - специальная формула
projectCount := float64(len(scores)) projectCount := float64(len(scores))
multiplier := 100.0 / (projectCount * 0.8) multiplier := 100.0 / (projectCount * 0.8)
sum := 0.0 sum := 0.0
for _, score := range scores { for _, score := range scores {
// Применяем корректировку перед использованием в формуле
adjustedScore := adjustScore(score)
// score уже в процентах (например, 80.0), переводим в долю (0.8) // score уже в процентах (например, 80.0), переводим в долю (0.8)
scoreAsDecimal := adjustedScore / 100.0 scoreAsDecimal := score / 100.0
sum += scoreAsDecimal * multiplier sum += scoreAsDecimal * multiplier
} }
@@ -4161,33 +4235,37 @@ func calculateGroupsProgress(groups map[int][]float64) GroupsProgress {
// calculateOverallProgress вычисляет общий процент выполнения на основе процентов групп // calculateOverallProgress вычисляет общий процент выполнения на основе процентов групп
// groupsProgress - структура с процентами для каждой группы приоритетов // groupsProgress - структура с процентами для каждой группы приоритетов
// groups - карта приоритетов к спискам calculatedScore проектов (используется для точного расчета)
// Возвращает указатель на float64 с общим процентом выполнения // Возвращает указатель на float64 с общим процентом выполнения
// Всегда вычисляет среднее всех трех групп (даже если какая-то группа отсутствует, она считается как 100%) // Вычисляет среднее между группами (min_goal = 100%, max_goal = 150%/135%/120%)
func calculateOverallProgress(groupsProgress GroupsProgress) *float64 { func calculateOverallProgress(groupsProgress GroupsProgress, groups map[int][]float64) *float64 {
// Находим среднее между всеми тремя группами // Собираем проценты по группам
// Если какая-то группа отсутствует (nil), считаем её как 100% var groupScores []float64
var group1Val, group2Val, group0Val float64
// Добавляем проценты только тех групп, которые существуют (имеют проекты)
if groupsProgress.Group1 != nil { if groupsProgress.Group1 != nil {
group1Val = *groupsProgress.Group1 groupScores = append(groupScores, *groupsProgress.Group1)
} else {
group1Val = 100.0
} }
if groupsProgress.Group2 != nil { if groupsProgress.Group2 != nil {
group2Val = *groupsProgress.Group2 groupScores = append(groupScores, *groupsProgress.Group2)
} else {
group2Val = 100.0
} }
if groupsProgress.Group0 != nil { if groupsProgress.Group0 != nil {
group0Val = *groupsProgress.Group0 groupScores = append(groupScores, *groupsProgress.Group0)
} else {
group0Val = 100.0
} }
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) overallProgressRounded := roundToFourDecimals(overallProgress)
total := &overallProgressRounded total := &overallProgressRounded
@@ -14623,6 +14701,650 @@ func (a *App) proxyImageHandler(w http.ResponseWriter, r *http.Request) {
w.Write(bodyBytes) 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 // decodeHTMLEntities декодирует базовые HTML entities
func decodeHTMLEntities(s string) string { func decodeHTMLEntities(s string) string {
replacements := map[string]string{ replacements := map[string]string{

View File

@@ -0,0 +1,2 @@
DROP TABLE IF EXISTS tracking_invite_tokens;
DROP TABLE IF EXISTS user_tracking;

View File

@@ -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);

View File

@@ -1,6 +1,6 @@
{ {
"name": "play-life-web", "name": "play-life-web",
"version": "4.22.0", "version": "4.23.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",

View File

@@ -16,6 +16,9 @@ import BoardForm from './components/BoardForm'
import BoardJoinPreview from './components/BoardJoinPreview' import BoardJoinPreview from './components/BoardJoinPreview'
import TodoistIntegration from './components/TodoistIntegration' import TodoistIntegration from './components/TodoistIntegration'
import TelegramIntegration from './components/TelegramIntegration' 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 { AuthProvider, useAuth } from './components/auth/AuthContext'
import AuthScreen from './components/auth/AuthScreen' import AuthScreen from './components/auth/AuthScreen'
import PWAUpdatePrompt from './components/PWAUpdatePrompt' 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 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() { function AppContent() {
const { authFetch, isAuthenticated, loading: authLoading } = useAuth() const { authFetch, isAuthenticated, loading: authLoading } = useAuth()
@@ -64,6 +67,9 @@ function AppContent() {
profile: false, profile: false,
'todoist-integration': false, 'todoist-integration': false,
'telegram-integration': false, 'telegram-integration': false,
tracking: false,
'tracking-access': false,
'tracking-invite': false,
}) })
// Отслеживаем, какие табы уже были загружены (для предотвращения повторных загрузок) // Отслеживаем, какие табы уже были загружены (для предотвращения повторных загрузок)
@@ -85,6 +91,9 @@ function AppContent() {
profile: false, profile: false,
'todoist-integration': false, 'todoist-integration': false,
'telegram-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 только для глубоких табов // Проверяем URL только для глубоких табов
const urlParams = new URLSearchParams(window.location.search) const urlParams = new URLSearchParams(window.location.search)
const tabFromUrl = urlParams.get('tab') 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)) { if (tabFromUrl && validTabs.includes(tabFromUrl) && deepTabs.includes(tabFromUrl)) {
// Если в URL есть глубокий таб, восстанавливаем его // Если в URL есть глубокий таб, восстанавливаем его
@@ -469,6 +491,9 @@ function AppContent() {
profile: false, profile: false,
'todoist-integration': false, 'todoist-integration': false,
'telegram-integration': false, 'telegram-integration': false,
tracking: false,
'tracking-access': false,
'tracking-invite': false,
}) })
// Используем ref для отслеживания кеша (чтобы не зависеть от состояния в useCallback) // Используем ref для отслеживания кеша (чтобы не зависеть от состояния в useCallback)
@@ -618,7 +643,7 @@ function AppContent() {
return 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 текущей записи истории (куда мы вернулись) // Проверяем state текущей записи истории (куда мы вернулись)
if (event.state && event.state.tab) { if (event.state && event.state.tab) {
@@ -915,7 +940,7 @@ function AppContent() {
}, [activeTab]) }, [activeTab])
// Определяем, нужно ли скрывать нижнюю панель (для fullscreen экранов) // Определяем, нужно ли скрывать нижнюю панель (для 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() {
</div> </div>
</div> </div>
)} )}
{loadedTabs.tracking && (
<div className={getTabContainerClasses('tracking')}>
<div className={getInnerContainerClasses('tracking')}>
<Tracking onNavigate={handleNavigate} activeTab={activeTab} />
</div>
</div>
)}
{loadedTabs['tracking-access'] && (
<div className={getTabContainerClasses('tracking-access')}>
<div className={getInnerContainerClasses('tracking-access')}>
<TrackingAccess onNavigate={handleNavigate} />
</div>
</div>
)}
{loadedTabs['tracking-invite'] && (
<div className={getTabContainerClasses('tracking-invite')}>
<div className={getInnerContainerClasses('tracking-invite')}>
<TrackingInviteAccept
inviteToken={tabParams.inviteToken}
onNavigate={handleNavigate}
/>
</div>
</div>
)}
</div> </div>
{/* Кнопка добавления задачи (только для таба задач) */} {/* Кнопка добавления задачи (только для таба задач) */}

View File

@@ -35,9 +35,10 @@ function Profile({ onNavigate }) {
</div> </div>
</div> </div>
{/* Admin Button */} {/* Admin & Tracking Buttons */}
{user?.is_admin && (
<div className="mb-6"> <div className="mb-6">
<div className="space-y-3">
{user?.is_admin && (
<button <button
onClick={() => { onClick={() => {
const adminUrl = window.location.origin + '/admin'; const adminUrl = window.location.origin + '/admin';
@@ -64,8 +65,32 @@ function Profile({ onNavigate }) {
</svg> </svg>
</div> </div>
</button> </button>
</div>
)} )}
<button
onClick={() => onNavigate?.('tracking')}
className="w-full p-4 bg-white rounded-xl shadow-sm hover:shadow-md transition-all text-left border border-gray-100 hover:border-indigo-200 group"
>
<div className="flex items-center justify-between">
<span className="text-gray-800 font-medium group-hover:text-indigo-600 transition-colors">
Отслеживание
</span>
<svg
className="w-5 h-5 text-gray-400 group-hover:text-indigo-500 transition-colors"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 5l7 7-7 7"
/>
</svg>
</div>
</button>
</div>
</div>
{/* Features Section */} {/* Features Section */}
<div className="mb-6"> <div className="mb-6">

View File

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

View File

@@ -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 (
<div className="tracking-screen max-w-2xl mx-auto">
{/* Заголовок с крестиком */}
<div className="tracking-header">
<h2 className="text-2xl font-semibold text-gray-800">Отслеживание</h2>
</div>
<button className="close-x-button" onClick={() => window.history.back()}></button>
{/* Чипсы недель с кнопкой доступов */}
<div className="week-controls">
<div className="week-chips-scroll" ref={scrollContainerRef}>
{weeks.map(w => (
<button
key={`${w.year}-${w.week}`}
ref={w.isCurrent ? currentWeekChipRef : null}
onClick={() => setSelectedWeek(w)}
className={`week-chip
${selectedWeek.year === w.year && selectedWeek.week === w.week ? 'selected' : ''}
${w.isCurrent ? 'current' : ''}`}
>
Неделя {w.week}
</button>
))}
</div>
<button
className="access-icon-btn"
onClick={() => onNavigate('tracking-access')}
title="Управление доступами"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path>
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path>
</svg>
</button>
</div>
{/* Контент */}
{loading ? (
<div className="loading-spinner">Загрузка...</div>
) : error ? (
<div className="error-message">{error}</div>
) : (
<div className="users-list">
{data?.users.map(user => (
<UserTrackingCard key={user.user_id} user={user} />
))}
</div>
)}
</div>
)
}
// Карточка пользователя с прогрессом
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 (
<div className={`user-tracking-card ${user.is_current_user ? 'current-user' : ''}`}>
<div className="user-header">
<span className="user-name">{user.user_name}</span>
<span className={`user-total ${getPercentColorClass(totalPercent)}`}>{totalPercent.toFixed(0)}%</span>
</div>
<div className="projects-list">
{sortedProjects.map((project, idx) => {
const projectPercent = project.calculated_score || 0
return (
<div key={idx} className="project-row">
<span className="project-name">{project.project_name}</span>
<span className={`project-score ${getPercentColorClass(projectPercent)}`}>{projectPercent.toFixed(0)}%</span>
</div>
)
})}
</div>
</div>
)
}
export default Tracking

View File

@@ -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 (
<div className="tracking-access-screen max-w-2xl mx-auto">
{/* Заголовок с крестиком */}
<div className="tracking-header">
<h2 className="text-2xl font-semibold text-gray-800">Управление доступами</h2>
</div>
<button className="close-x-button" onClick={() => window.history.back()}></button>
{/* Секция создания ссылки */}
<div className="access-section">
<h3>Поделиться статистикой</h3>
<p className="section-hint">Создайте одноразовую ссылку (действует 1 час)</p>
<button
className={`create-invite-btn ${copied ? 'copied' : ''}`}
onClick={handleCreateInvite}
disabled={generating}
>
{generating ? 'Создание...' : copied ? '✓ Ссылка скопирована' : 'Создать и скопировать ссылку'}
</button>
</div>
{/* Список: кто меня отслеживает */}
<div className="access-section">
<h3>Меня отслеживают ({trackers.length})</h3>
{trackers.length === 0 ? (
<p className="empty-list">Пока никто не отслеживает вашу статистику</p>
) : (
trackers.map(t => (
<div key={t.relation_id} className="access-item">
<span className="access-item-name">{t.name}</span>
<button className="remove-btn" onClick={() => handleRemoveTracker(t.relation_id)}>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M3 6h18M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2"/>
</svg>
</button>
</div>
))
)}
</div>
{/* Список: кого я отслеживаю */}
<div className="access-section">
<h3>Я отслеживаю ({tracked.length})</h3>
{tracked.length === 0 ? (
<p className="empty-list">Вы пока никого не отслеживаете</p>
) : (
tracked.map(t => (
<div key={t.relation_id} className="access-item">
<span className="access-item-name">{t.name}</span>
<button className="remove-btn" onClick={() => handleRemoveTracked(t.relation_id)}>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M3 6h18M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2"/>
</svg>
</button>
</div>
))
)}
</div>
{toastMessage && (
<Toast message={toastMessage.text} type={toastMessage.type} onClose={() => setToastMessage(null)} />
)}
</div>
)
}
export default TrackingAccess

View File

@@ -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 (
<div className="tracking-invite-screen">
<div className="loading-container">
<div className="spinner"></div>
<p>Загрузка...</p>
</div>
</div>
)
}
if (error && !inviteInfo) {
return (
<div className="tracking-invite-screen">
<div className="invite-card error-card">
<div className="invite-icon"></div>
<h2>Ошибка</h2>
<p className="error-text">{error}</p>
<button className="secondary-btn" onClick={handleClose}>
Перейти к отслеживанию
</button>
</div>
</div>
)
}
return (
<div className="tracking-invite-screen">
<button className="close-x-button" onClick={handleClose}></button>
<div className="invite-card">
<div className="invite-icon">👁</div>
<h2>Приглашение на отслеживание</h2>
<div className="invite-info">
<p>Вы сможете видеть статистику пользователя:</p>
<p className="user-name-large">{inviteInfo?.user_name}</p>
</div>
{error && <p className="error-inline">{error}</p>}
<button
className="accept-btn"
onClick={handleAccept}
disabled={accepting}
>
{accepting ? 'Принятие...' : 'Начать отслеживать'}
</button>
<button className="cancel-link" onClick={handleClose}>
Отмена
</button>
</div>
</div>
)
}
export default TrackingInviteAccept