diff --git a/VERSION b/VERSION index 2009c7d..820476a 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -3.9.2 +3.9.3 diff --git a/play-life-backend/main.go b/play-life-backend/main.go index 5381bb4..c876f9f 100644 --- a/play-life-backend/main.go +++ b/play-life-backend/main.go @@ -293,7 +293,7 @@ type UnlockConditionDisplay struct { TaskName *string `json:"task_name,omitempty"` ProjectName *string `json:"project_name,omitempty"` RequiredPoints *float64 `json:"required_points,omitempty"` - PeriodType *string `json:"period_type,omitempty"` + StartDate *string `json:"start_date,omitempty"` // Дата начала подсчёта (YYYY-MM-DD), NULL = за всё время DisplayOrder int `json:"display_order"` // Прогресс выполнения CurrentPoints *float64 `json:"current_points,omitempty"` // Текущее количество баллов (для project_points) @@ -312,7 +312,7 @@ type UnlockConditionRequest struct { TaskID *int `json:"task_id,omitempty"` ProjectID *int `json:"project_id,omitempty"` RequiredPoints *float64 `json:"required_points,omitempty"` - PeriodType *string `json:"period_type,omitempty"` + StartDate *string `json:"start_date,omitempty"` // Дата начала подсчёта (YYYY-MM-DD), NULL = за всё время DisplayOrder *int `json:"display_order,omitempty"` } @@ -2816,6 +2816,12 @@ func (a *App) initAuthDB() error { // Не возвращаем ошибку, чтобы приложение могло запуститься } + // Apply migration 020: Change period_type to start_date in score_conditions + if err := a.applyMigration020(); err != nil { + log.Printf("Warning: Failed to apply migration 020: %v", err) + // Не возвращаем ошибку, чтобы приложение могло запуститься + } + // Clean up expired refresh tokens (only those with expiration date set) a.DB.Exec("DELETE FROM refresh_tokens WHERE expires_at IS NOT NULL AND expires_at < NOW()") @@ -2993,6 +2999,52 @@ func (a *App) applyMigration019() error { return nil } +// applyMigration020 применяет миграцию 020_change_period_to_start_date.sql +func (a *App) applyMigration020() error { + log.Printf("Applying migration 020: Change period_type to start_date in score_conditions") + + // Проверяем, существует ли уже поле start_date + var exists bool + err := a.DB.QueryRow(` + SELECT EXISTS ( + SELECT FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = 'score_conditions' + AND column_name = 'start_date' + ) + `).Scan(&exists) + if err != nil { + return fmt.Errorf("failed to check if start_date exists: %w", err) + } + if exists { + log.Printf("Migration 020 already applied (start_date column exists), skipping") + return nil + } + + // Читаем SQL файл миграции + migrationPath := "/migrations/020_change_period_to_start_date.sql" + if _, err := os.Stat(migrationPath); os.IsNotExist(err) { + // Пробуем альтернативный путь (для локальной разработки) + migrationPath = "play-life-backend/migrations/020_change_period_to_start_date.sql" + if _, err := os.Stat(migrationPath); os.IsNotExist(err) { + migrationPath = "migrations/020_change_period_to_start_date.sql" + } + } + + migrationSQL, err := os.ReadFile(migrationPath) + if err != nil { + return fmt.Errorf("failed to read migration file %s: %w", migrationPath, err) + } + + // Выполняем миграцию + if _, err := a.DB.Exec(string(migrationSQL)); err != nil { + return fmt.Errorf("failed to execute migration 020: %w", err) + } + + log.Printf("Migration 020 applied successfully") + return nil +} + func (a *App) initPlayLifeDB() error { // Создаем таблицу projects createProjectsTable := ` @@ -8427,10 +8479,10 @@ func (a *App) todoistDisconnectHandler(w http.ResponseWriter, r *http.Request) { // Wishlist handlers // ============================================ -// calculateProjectPointsForPeriod считает баллы проекта за указанный период -func (a *App) calculateProjectPointsForPeriod( +// calculateProjectPointsFromDate считает баллы проекта с указанной даты до текущего момента +func (a *App) calculateProjectPointsFromDate( projectID int, - periodType sql.NullString, + startDate sql.NullTime, userID int, ) (float64, error) { var totalScore float64 @@ -8442,7 +8494,7 @@ func (a *App) calculateProjectPointsForPeriod( log.Printf("Warning: Failed to refresh materialized view: %v", err) } - if !periodType.Valid || periodType.String == "" { + if !startDate.Valid { // За всё время err = a.DB.QueryRow(` SELECT COALESCE(SUM(wr.total_score), 0) @@ -8451,46 +8503,21 @@ func (a *App) calculateProjectPointsForPeriod( WHERE wr.project_id = $1 AND p.user_id = $2 `, projectID, userID).Scan(&totalScore) } else { - switch periodType.String { - case "week": - // Текущая неделя - err = a.DB.QueryRow(` - SELECT COALESCE(SUM(wr.total_score), 0) - FROM weekly_report_mv wr - JOIN projects p ON wr.project_id = p.id - WHERE wr.project_id = $1 - AND p.user_id = $2 - AND wr.report_year = EXTRACT(ISOYEAR FROM CURRENT_DATE)::INTEGER - AND wr.report_week = EXTRACT(WEEK FROM CURRENT_DATE)::INTEGER - `, projectID, userID).Scan(&totalScore) - - case "month": - // Текущий месяц - err = a.DB.QueryRow(` - SELECT COALESCE(SUM(wr.total_score), 0) - FROM weekly_report_mv wr - JOIN projects p ON wr.project_id = p.id - WHERE wr.project_id = $1 - AND p.user_id = $2 - AND wr.report_year = EXTRACT(ISOYEAR FROM CURRENT_DATE)::INTEGER - AND wr.report_week >= EXTRACT(WEEK FROM DATE_TRUNC('month', CURRENT_DATE))::INTEGER - AND wr.report_week <= EXTRACT(WEEK FROM (DATE_TRUNC('month', CURRENT_DATE) + INTERVAL '1 month' - INTERVAL '1 day'))::INTEGER - `, projectID, userID).Scan(&totalScore) - - case "year": - // Текущий год - err = a.DB.QueryRow(` - SELECT COALESCE(SUM(wr.total_score), 0) - FROM weekly_report_mv wr - JOIN projects p ON wr.project_id = p.id - WHERE wr.project_id = $1 - AND p.user_id = $2 - AND wr.report_year = EXTRACT(ISOYEAR FROM CURRENT_DATE)::INTEGER - `, projectID, userID).Scan(&totalScore) - - default: - return 0, fmt.Errorf("unknown period_type: %s", periodType.String) - } + // С указанной даты до текущего момента + // Нужно найти все недели, которые попадают в диапазон от startDate до CURRENT_DATE + // Используем сравнение (year, week) >= (startDate_year, startDate_week) + err = a.DB.QueryRow(` + SELECT COALESCE(SUM(wr.total_score), 0) + FROM weekly_report_mv wr + JOIN projects p ON wr.project_id = p.id + WHERE wr.project_id = $1 + AND p.user_id = $2 + AND ( + wr.report_year > EXTRACT(ISOYEAR FROM $3)::INTEGER + OR (wr.report_year = EXTRACT(ISOYEAR FROM $3)::INTEGER + AND wr.report_week >= EXTRACT(WEEK FROM $3)::INTEGER) + ) + `, projectID, userID, startDate.Time).Scan(&totalScore) } if err != nil { @@ -8513,7 +8540,7 @@ func (a *App) checkWishlistUnlock(itemID int, userID int) (bool, error) { tc.task_id, sc.project_id, sc.required_points, - sc.period_type + sc.start_date FROM wishlist_conditions wc LEFT JOIN task_conditions tc ON wc.task_condition_id = tc.id LEFT JOIN score_conditions sc ON wc.score_condition_id = sc.id @@ -8537,12 +8564,12 @@ func (a *App) checkWishlistUnlock(itemID int, userID int) (bool, error) { var taskID sql.NullInt64 var projectID sql.NullInt64 var requiredPoints sql.NullFloat64 - var periodType sql.NullString + var startDate sql.NullTime err := rows.Scan( &wcID, &displayOrder, &taskConditionID, &scoreConditionID, - &taskID, &projectID, &requiredPoints, &periodType, + &taskID, &projectID, &requiredPoints, &startDate, ) if err != nil { return false, err @@ -8577,9 +8604,9 @@ func (a *App) checkWishlistUnlock(itemID int, userID int) (bool, error) { return false, fmt.Errorf("project_id or required_points missing for score_condition_id=%d", scoreConditionID.Int64) } - totalScore, err := a.calculateProjectPointsForPeriod( + totalScore, err := a.calculateProjectPointsFromDate( int(projectID.Int64), - periodType, + startDate, userID, ) if err != nil { @@ -8623,7 +8650,7 @@ func (a *App) getWishlistItemsWithConditions(userID int, includeCompleted bool) sc.project_id, p.name AS project_name, sc.required_points, - sc.period_type + sc.start_date FROM wishlist_items wi LEFT JOIN wishlist_conditions wc ON wi.id = wc.wishlist_item_id LEFT JOIN task_conditions tc ON wc.task_condition_id = tc.id @@ -8659,14 +8686,14 @@ func (a *App) getWishlistItemsWithConditions(userID int, includeCompleted bool) var projectID sql.NullInt64 var projectName sql.NullString var requiredPoints sql.NullFloat64 - var periodType sql.NullString + var startDate sql.NullTime err := rows.Scan( &itemID, &name, &price, &imagePath, &link, &completed, &conditionID, &displayOrder, &taskConditionID, &scoreConditionID, &taskID, &taskName, - &projectID, &projectName, &requiredPoints, &periodType, + &projectID, &projectName, &requiredPoints, &startDate, ) if err != nil { return nil, err @@ -8716,8 +8743,10 @@ func (a *App) getWishlistItemsWithConditions(userID int, includeCompleted bool) if requiredPoints.Valid { condition.RequiredPoints = &requiredPoints.Float64 } - if periodType.Valid { - condition.PeriodType = &periodType.String + if startDate.Valid { + // Форматируем дату в YYYY-MM-DD + dateStr := startDate.Time.Format("2006-01-02") + condition.StartDate = &dateStr } } @@ -8767,15 +8796,15 @@ func (a *App) getWishlistItemsWithConditions(userID int, includeCompleted bool) // Находим project_id и required_points для этого условия var projectID int var requiredPoints float64 - var periodType sql.NullString + var startDate sql.NullTime err = a.DB.QueryRow(` - SELECT sc.project_id, sc.required_points, sc.period_type + SELECT sc.project_id, sc.required_points, sc.start_date FROM wishlist_conditions wc JOIN score_conditions sc ON wc.score_condition_id = sc.id WHERE wc.id = $1 - `, condition.ID).Scan(&projectID, &requiredPoints, &periodType) + `, condition.ID).Scan(&projectID, &requiredPoints, &startDate) if err == nil { - totalScore, err := a.calculateProjectPointsForPeriod(projectID, periodType, userID) + totalScore, err := a.calculateProjectPointsFromDate(projectID, startDate, userID) conditionMet = err == nil && totalScore >= requiredPoints if err == nil { condition.CurrentPoints = &totalScore @@ -8819,15 +8848,15 @@ func (a *App) getWishlistItemsWithConditions(userID int, includeCompleted bool) } else if condition.Type == "project_points" { var projectID int var requiredPoints float64 - var periodType sql.NullString + var startDate sql.NullTime err := a.DB.QueryRow(` - SELECT sc.project_id, sc.required_points, sc.period_type + SELECT sc.project_id, sc.required_points, sc.start_date FROM wishlist_conditions wc JOIN score_conditions sc ON wc.score_condition_id = sc.id WHERE wc.id = $1 - `, condition.ID).Scan(&projectID, &requiredPoints, &periodType) + `, condition.ID).Scan(&projectID, &requiredPoints, &startDate) if err == nil { - totalScore, err := a.calculateProjectPointsForPeriod(projectID, periodType, userID) + totalScore, err := a.calculateProjectPointsFromDate(projectID, startDate, userID) if err == nil { condition.CurrentPoints = &totalScore } @@ -8914,34 +8943,35 @@ func (a *App) saveWishlistConditions( return fmt.Errorf("project_id and required_points are required for project_points") } - periodType := condition.PeriodType + startDateStr := condition.StartDate // Получаем или создаём score_condition var scID int - var periodTypeVal interface{} - if periodType != nil && *periodType != "" { - periodTypeVal = *periodType + var startDateVal interface{} + if startDateStr != nil && *startDateStr != "" { + // Парсим дату из строки YYYY-MM-DD + startDateVal = *startDateStr } else { // Пустая строка или nil = NULL для "за всё время" - periodTypeVal = nil + startDateVal = nil } err := tx.QueryRow(` SELECT id FROM score_conditions WHERE project_id = $1 AND required_points = $2 - AND (period_type = $3 OR (period_type IS NULL AND $3 IS NULL)) - `, *condition.ProjectID, *condition.RequiredPoints, periodTypeVal).Scan(&scID) + AND (start_date = $3::DATE OR (start_date IS NULL AND $3 IS NULL)) + `, *condition.ProjectID, *condition.RequiredPoints, startDateVal).Scan(&scID) if err == sql.ErrNoRows { // Создаём новое условие err = tx.QueryRow(` - INSERT INTO score_conditions (project_id, required_points, period_type) - VALUES ($1, $2, $3) - ON CONFLICT (project_id, required_points, period_type) + INSERT INTO score_conditions (project_id, required_points, start_date) + VALUES ($1, $2, $3::DATE) + ON CONFLICT (project_id, required_points, start_date) DO UPDATE SET project_id = EXCLUDED.project_id RETURNING id - `, *condition.ProjectID, *condition.RequiredPoints, periodTypeVal).Scan(&scID) + `, *condition.ProjectID, *condition.RequiredPoints, startDateVal).Scan(&scID) if err != nil { return err } diff --git a/play-life-backend/migrations/020_change_period_to_start_date.sql b/play-life-backend/migrations/020_change_period_to_start_date.sql new file mode 100644 index 0000000..2727b15 --- /dev/null +++ b/play-life-backend/migrations/020_change_period_to_start_date.sql @@ -0,0 +1,37 @@ +-- Migration: Change period_type to start_date in score_conditions +-- This allows specifying a start date for counting points instead of period type +-- Date can be in the past or future, NULL means count all time + +-- Добавляем новое поле start_date +ALTER TABLE score_conditions +ADD COLUMN IF NOT EXISTS start_date DATE; + +-- Миграция данных: для существующих записей с period_type устанавливаем start_date +-- Если period_type = 'week', то start_date = начало текущей недели +-- Если period_type = 'month', то start_date = начало текущего месяца +-- Если period_type = 'year', то start_date = начало текущего года +-- Если period_type IS NULL, то start_date = NULL (за всё время) +UPDATE score_conditions +SET start_date = CASE + WHEN period_type = 'week' THEN DATE_TRUNC('week', CURRENT_DATE)::DATE + WHEN period_type = 'month' THEN DATE_TRUNC('month', CURRENT_DATE)::DATE + WHEN period_type = 'year' THEN DATE_TRUNC('year', CURRENT_DATE)::DATE + ELSE NULL +END +WHERE start_date IS NULL; + +-- Обновляем уникальное ограничение (удаляем старое, добавляем новое) +ALTER TABLE score_conditions +DROP CONSTRAINT IF EXISTS unique_score_condition; + +ALTER TABLE score_conditions +ADD CONSTRAINT unique_score_condition +UNIQUE (project_id, required_points, start_date); + +-- Обновляем комментарии +COMMENT ON COLUMN score_conditions.start_date IS 'Date from which to start counting points. NULL means count all time.'; + +-- Примечание: поле period_type оставляем пока для обратной совместимости +-- Его можно будет удалить позже после проверки, что всё работает: +-- ALTER TABLE score_conditions DROP COLUMN period_type; + diff --git a/play-life-web/src/components/WishlistDetail.jsx b/play-life-web/src/components/WishlistDetail.jsx index 02a6ad0..148eba4 100644 --- a/play-life-web/src/components/WishlistDetail.jsx +++ b/play-life-web/src/components/WishlistDetail.jsx @@ -166,16 +166,14 @@ function WishlistDetail({ wishlistId, onNavigate, onRefresh }) { const requiredPoints = condition.required_points || 0 const currentPoints = condition.current_points || 0 const project = condition.project_name || 'Проект' - let period = '' - if (condition.period_type) { - const periodLabels = { - week: 'за неделю', - month: 'за месяц', - year: 'за год', - } - period = ' ' + periodLabels[condition.period_type] || '' + let dateText = '' + if (condition.start_date) { + const date = new Date(condition.start_date + 'T00:00:00') + dateText = ` с ${date.toLocaleDateString('ru-RU')}` + } else { + dateText = ' за всё время' } - conditionText = `${requiredPoints} в ${project}${period}` + conditionText = `${requiredPoints} в ${project}${dateText}` progress = { type: 'points', current: currentPoints, diff --git a/play-life-web/src/components/WishlistForm.css b/play-life-web/src/components/WishlistForm.css index a762897..06daae4 100644 --- a/play-life-web/src/components/WishlistForm.css +++ b/play-life-web/src/components/WishlistForm.css @@ -383,3 +383,67 @@ } } +/* Date Selector Styles (аналогично task-postpone-input-group) */ +.date-selector-input-group { + display: flex; + gap: 0.5rem; + align-items: center; + position: relative; +} + +.date-selector-input { + position: absolute; + opacity: 0; + width: 0; + height: 0; + pointer-events: none; +} + +.date-selector-display-date { + flex: 1; + padding: 0.75rem; + border: 1px solid #d1d5db; + border-radius: 0.375rem; + font-size: 1rem; + background: white; + cursor: pointer; + transition: all 0.2s; + color: #1f2937; + user-select: none; +} + +.date-selector-display-date:hover { + border-color: #3498db; + background: #f9fafb; +} + +.date-selector-display-date:active { + background: #f3f4f6; +} + +.date-selector-clear-button { + padding: 0.5rem; + background: #e5e7eb; + color: #6b7280; + border: none; + border-radius: 50%; + width: 32px; + height: 32px; + cursor: pointer; + font-size: 0.875rem; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s; + flex-shrink: 0; +} + +.date-selector-clear-button:hover { + background: #d1d5db; + color: #374151; +} + +.date-selector-clear-button:active { + transform: scale(0.95); +} + diff --git a/play-life-web/src/components/WishlistForm.jsx b/play-life-web/src/components/WishlistForm.jsx index 5e51ee4..2017bb8 100644 --- a/play-life-web/src/components/WishlistForm.jsx +++ b/play-life-web/src/components/WishlistForm.jsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useCallback } from 'react' +import React, { useState, useEffect, useCallback, useRef } from 'react' import Cropper from 'react-easy-crop' import { useAuth } from './auth/AuthContext' import Toast from './Toast' @@ -80,7 +80,7 @@ function WishlistForm({ onNavigate, wishlistId }) { task_id: cond.type === 'task_completion' ? tasks.find(t => t.name === cond.task_name)?.id : null, project_id: cond.type === 'project_points' ? projects.find(p => p.project_name === cond.project_name)?.project_id : null, required_points: cond.required_points || null, - period_type: cond.period_type || null, + start_date: cond.start_date || null, display_order: idx, }))) } @@ -289,7 +289,7 @@ function WishlistForm({ onNavigate, wishlistId }) { task_id: cond.type === 'task_completion' ? cond.task_id : null, project_id: cond.type === 'project_points' ? cond.project_id : null, required_points: cond.type === 'project_points' ? parseFloat(cond.required_points) : null, - period_type: cond.type === 'project_points' ? cond.period_type : null, + start_date: cond.type === 'project_points' ? cond.start_date : null, })), } @@ -498,7 +498,7 @@ function WishlistForm({ onNavigate, wishlistId }) { {cond.type === 'task_completion' ? `Задача: ${tasks.find(t => t.id === cond.task_id)?.name || 'Не выбрана'}` - : `Баллы: ${cond.required_points} в ${projects.find(p => p.project_id === cond.project_id)?.project_name || 'Не выбран'}${cond.period_type ? ` за ${cond.period_type === 'week' ? 'неделю' : cond.period_type === 'month' ? 'месяц' : 'год'}` : ''}`} + : `Баллы: ${cond.required_points} в ${projects.find(p => p.project_id === cond.project_id)?.project_name || 'Не выбран'}${cond.start_date ? ` с ${new Date(cond.start_date + 'T00:00:00').toLocaleDateString('ru-RU')}` : ' за всё время'}`} + )} + + ) +} + // Компонент формы условия разблокировки function ConditionForm({ tasks, projects, onSubmit, onCancel }) { - const [type, setType] = useState('task_completion') + const [type, setType] = useState('project_points') const [taskId, setTaskId] = useState('') const [projectId, setProjectId] = useState('') const [requiredPoints, setRequiredPoints] = useState('') - const [periodType, setPeriodType] = useState('') + const [startDate, setStartDate] = useState('') const handleSubmit = (e) => { e.preventDefault() @@ -574,15 +652,15 @@ function ConditionForm({ tasks, projects, onSubmit, onCancel }) { task_id: type === 'task_completion' ? parseInt(taskId) : null, project_id: type === 'project_points' ? parseInt(projectId) : null, required_points: type === 'project_points' ? parseFloat(requiredPoints) : null, - period_type: type === 'project_points' && periodType ? periodType : null, + start_date: type === 'project_points' && startDate ? startDate : null, } onSubmit(condition) // Сброс формы - setType('task_completion') + setType('project_points') setTaskId('') setProjectId('') setRequiredPoints('') - setPeriodType('') + setStartDate('') } return ( @@ -597,8 +675,8 @@ function ConditionForm({ tasks, projects, onSubmit, onCancel }) { onChange={(e) => setType(e.target.value)} className="form-input" > - - + + @@ -649,17 +727,12 @@ function ConditionForm({ tasks, projects, onSubmit, onCancel }) { />
- - + +
)}