v3.1.0: Оптимизация загрузки списка задач - все данные в одном запросе, добавлены индикаторы подзадач и прогрессии
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 40s

This commit is contained in:
poignatov
2026-01-06 14:54:37 +03:00
parent 28d8148665
commit 0ea531889d
5 changed files with 138 additions and 114 deletions

View File

@@ -1 +1 @@
3.0.1 3.1.0

View File

@@ -28,6 +28,7 @@ import (
"github.com/gorilla/mux" "github.com/gorilla/mux"
"github.com/joho/godotenv" "github.com/joho/godotenv"
_ "github.com/lib/pq" _ "github.com/lib/pq"
"github.com/lib/pq"
"github.com/robfig/cron/v3" "github.com/robfig/cron/v3"
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
) )
@@ -207,6 +208,10 @@ type Task struct {
RewardMessage *string `json:"reward_message,omitempty"` RewardMessage *string `json:"reward_message,omitempty"`
ProgressionBase *float64 `json:"progression_base,omitempty"` ProgressionBase *float64 `json:"progression_base,omitempty"`
RepetitionPeriod *string `json:"repetition_period,omitempty"` RepetitionPeriod *string `json:"repetition_period,omitempty"`
// Дополнительные поля для списка задач (без omitempty чтобы всегда передавались)
ProjectNames []string `json:"project_names"`
SubtasksCount int `json:"subtasks_count"`
HasProgression bool `json:"has_progression"`
} }
type Reward struct { type Reward struct {
@@ -6248,13 +6253,40 @@ func (a *App) getTasksHandler(w http.ResponseWriter, r *http.Request) {
return return
} }
// Запрос с получением всех необходимых данных для группировки и отображения
query := ` query := `
SELECT id, name, completed, last_completed_at, repetition_period::text SELECT
FROM tasks t.id,
WHERE user_id = $1 AND parent_task_id IS NULL AND deleted = FALSE t.name,
t.completed,
t.last_completed_at,
t.repetition_period::text,
t.progression_base,
COALESCE((
SELECT COUNT(*)
FROM tasks st
WHERE st.parent_task_id = t.id AND st.deleted = FALSE
), 0) as subtasks_count,
COALESCE(
(SELECT array_agg(DISTINCT p.name) FILTER (WHERE p.name IS NOT NULL)
FROM reward_configs rc
JOIN projects p ON rc.project_id = p.id
WHERE rc.task_id = t.id),
ARRAY[]::text[]
) as project_names,
COALESCE(
(SELECT array_agg(DISTINCT p.name) FILTER (WHERE p.name IS NOT NULL)
FROM tasks st
JOIN reward_configs rc ON rc.task_id = st.id
JOIN projects p ON rc.project_id = p.id
WHERE st.parent_task_id = t.id AND st.deleted = FALSE),
ARRAY[]::text[]
) as subtask_project_names
FROM tasks t
WHERE t.user_id = $1 AND t.parent_task_id IS NULL AND t.deleted = FALSE
ORDER BY ORDER BY
CASE WHEN last_completed_at IS NULL OR last_completed_at::date < CURRENT_DATE THEN 0 ELSE 1 END, CASE WHEN t.last_completed_at IS NULL OR t.last_completed_at::date < CURRENT_DATE THEN 0 ELSE 1 END,
name t.name
` `
rows, err := a.DB.Query(query, userID) rows, err := a.DB.Query(query, userID)
@@ -6270,8 +6302,21 @@ func (a *App) getTasksHandler(w http.ResponseWriter, r *http.Request) {
var task Task var task Task
var lastCompletedAt sql.NullString var lastCompletedAt sql.NullString
var repetitionPeriod sql.NullString var repetitionPeriod sql.NullString
var progressionBase sql.NullFloat64
var projectNames pq.StringArray
var subtaskProjectNames pq.StringArray
err := rows.Scan(&task.ID, &task.Name, &task.Completed, &lastCompletedAt, &repetitionPeriod) err := rows.Scan(
&task.ID,
&task.Name,
&task.Completed,
&lastCompletedAt,
&repetitionPeriod,
&progressionBase,
&task.SubtasksCount,
&projectNames,
&subtaskProjectNames,
)
if err != nil { if err != nil {
log.Printf("Error scanning task: %v", err) log.Printf("Error scanning task: %v", err)
continue continue
@@ -6283,6 +6328,30 @@ func (a *App) getTasksHandler(w http.ResponseWriter, r *http.Request) {
if repetitionPeriod.Valid { if repetitionPeriod.Valid {
task.RepetitionPeriod = &repetitionPeriod.String task.RepetitionPeriod = &repetitionPeriod.String
} }
if progressionBase.Valid {
task.HasProgression = true
task.ProgressionBase = &progressionBase.Float64
} else {
task.HasProgression = false
}
// Объединяем проекты из основной задачи и подзадач
allProjects := make(map[string]bool)
for _, pn := range projectNames {
if pn != "" {
allProjects[pn] = true
}
}
for _, pn := range subtaskProjectNames {
if pn != "" {
allProjects[pn] = true
}
}
task.ProjectNames = make([]string, 0, len(allProjects))
for pn := range allProjects {
task.ProjectNames = append(task.ProjectNames, pn)
}
tasks = append(tasks, task) tasks = append(tasks, task)
} }

View File

@@ -89,11 +89,31 @@
color: #8b5cf6; color: #8b5cf6;
} }
.task-name-container {
flex: 1;
display: flex;
align-items: center;
gap: 0.5rem;
}
.task-name { .task-name {
font-size: 1rem; font-size: 1rem;
font-weight: 500; font-weight: 500;
color: #1f2937; color: #1f2937;
flex: 1; display: flex;
align-items: center;
gap: 0.25rem;
}
.task-subtasks-count {
color: #9ca3af;
font-size: 0.875rem;
font-weight: 400;
}
.task-progression-icon {
color: #9ca3af;
flex-shrink: 0;
} }
.task-actions { .task-actions {

View File

@@ -10,8 +10,6 @@ function TaskList({ onNavigate, data, loading, backgroundLoading, onRefresh }) {
const { authFetch } = useAuth() const { authFetch } = useAuth()
// Инициализируем tasks из data, если data есть, иначе пустой массив // Инициализируем tasks из data, если data есть, иначе пустой массив
const [tasks, setTasks] = useState(() => data && Array.isArray(data) ? data : []) const [tasks, setTasks] = useState(() => data && Array.isArray(data) ? data : [])
const [taskDetails, setTaskDetails] = useState({})
const [loadingDetails, setLoadingDetails] = useState(false)
const [selectedTaskForDetail, setSelectedTaskForDetail] = useState(null) const [selectedTaskForDetail, setSelectedTaskForDetail] = useState(null)
const [isCompleting, setIsCompleting] = useState(false) const [isCompleting, setIsCompleting] = useState(false)
const [expandedCompleted, setExpandedCompleted] = useState({}) const [expandedCompleted, setExpandedCompleted] = useState({})
@@ -26,9 +24,6 @@ function TaskList({ onNavigate, data, loading, backgroundLoading, onRefresh }) {
}) })
const [toast, setToast] = useState(null) const [toast, setToast] = useState(null)
// Для отслеживания изменений в списке задач (чтобы не перезагружать детали без необходимости)
const lastTaskIdsRef = useRef('')
useEffect(() => { useEffect(() => {
if (data) { if (data) {
setTasks(data) setTasks(data)
@@ -38,64 +33,6 @@ function TaskList({ onNavigate, data, loading, backgroundLoading, onRefresh }) {
// Загрузка данных управляется из App.jsx через loadTabData // Загрузка данных управляется из App.jsx через loadTabData
// TaskList не инициирует загрузку самостоятельно // TaskList не инициирует загрузку самостоятельно
// Загружаем детали для всех задач
// Оптимизация: загружаем только если список задач изменился (по id и last_completed_at)
useEffect(() => {
if (!tasks || tasks.length === 0) {
setTaskDetails({})
lastTaskIdsRef.current = ''
return
}
// Создаем ключ из id и last_completed_at всех задач
const taskKey = tasks.map(t => `${t.id}:${t.last_completed_at || ''}`).sort().join(',')
// Если ключ не изменился, не перезагружаем детали
if (taskKey === lastTaskIdsRef.current) {
return
}
lastTaskIdsRef.current = taskKey
const loadTaskDetails = async () => {
// Не показываем индикатор загрузки если детали уже есть (фоновое обновление)
const hasExistingDetails = Object.keys(taskDetails).length > 0
if (!hasExistingDetails) {
setLoadingDetails(true)
}
try {
const detailPromises = tasks.map(async (task) => {
try {
const response = await authFetch(`${API_URL}/${task.id}`)
if (response.ok) {
const detail = await response.json()
return { taskId: task.id, detail }
}
} catch (err) {
console.error(`Error loading task detail for ${task.id}:`, err)
}
return null
})
const details = await Promise.all(detailPromises)
const detailsMap = {}
details.forEach(item => {
if (item) {
detailsMap[item.taskId] = item.detail
}
})
setTaskDetails(detailsMap)
} catch (err) {
console.error('Error loading task details:', err)
} finally {
setLoadingDetails(false)
}
}
loadTaskDetails()
}, [tasks, authFetch])
const handleTaskClick = (task) => { const handleTaskClick = (task) => {
onNavigate?.('task-form', { taskId: task.id }) onNavigate?.('task-form', { taskId: task.id })
} }
@@ -103,9 +40,8 @@ function TaskList({ onNavigate, data, loading, backgroundLoading, onRefresh }) {
const handleCheckmarkClick = async (task, e) => { const handleCheckmarkClick = async (task, e) => {
e.stopPropagation() e.stopPropagation()
const detail = taskDetails[task.id] const hasProgression = task.has_progression || task.progression_base != null
const hasProgression = detail?.task?.progression_base != null const hasSubtasks = task.subtasks_count > 0
const hasSubtasks = detail?.subtasks && detail.subtasks.length > 0
if (hasProgression || hasSubtasks) { if (hasProgression || hasSubtasks) {
// Открываем экран details // Открываем экран details
@@ -174,36 +110,12 @@ function TaskList({ onNavigate, data, loading, backgroundLoading, onRefresh }) {
}) })
} }
// Получаем все проекты из наград задачи и подзадач // Получаем все проекты из задачи (теперь они приходят в task.project_names)
const getTaskProjects = (task) => { const getTaskProjects = (task) => {
const projects = new Set() if (task.project_names && Array.isArray(task.project_names)) {
const detail = taskDetails[task.id] return task.project_names
if (detail) {
// Проекты из основной задачи
if (detail.rewards) {
detail.rewards.forEach(reward => {
if (reward.project_name) {
projects.add(reward.project_name)
}
})
}
// Проекты из подзадач
if (detail.subtasks) {
detail.subtasks.forEach(subtask => {
if (subtask.rewards) {
subtask.rewards.forEach(reward => {
if (reward.project_name) {
projects.add(reward.project_name)
}
})
}
})
}
} }
return []
return Array.from(projects)
} }
// Функция для проверки, является ли период нулевым // Функция для проверки, является ли период нулевым
@@ -340,12 +252,11 @@ function TaskList({ onNavigate, data, loading, backgroundLoading, onRefresh }) {
}) })
return groups return groups
}, [tasks, taskDetails]) }, [tasks])
const renderTaskItem = (task) => { const renderTaskItem = (task) => {
const detail = taskDetails[task.id] const hasProgression = task.has_progression || task.progression_base != null
const hasProgression = detail?.task?.progression_base != null const hasSubtasks = task.subtasks_count > 0
const hasSubtasks = detail?.subtasks && detail.subtasks.length > 0
const showDetailOnCheckmark = hasProgression || hasSubtasks const showDetailOnCheckmark = hasProgression || hasSubtasks
return ( return (
@@ -365,7 +276,31 @@ function TaskList({ onNavigate, data, loading, backgroundLoading, onRefresh }) {
<path d="M6 10 L9 13 L14 7" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="checkmark-check" /> <path d="M6 10 L9 13 L14 7" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="checkmark-check" />
</svg> </svg>
</div> </div>
<div className="task-name">{task.name}</div> <div className="task-name-container">
<div className="task-name">
{task.name}
{hasSubtasks && (
<span className="task-subtasks-count">(+{task.subtasks_count})</span>
)}
</div>
{hasProgression && (
<svg
className="task-progression-icon"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
title="Задача с прогрессией"
>
<polyline points="23 6 13.5 15.5 8.5 10.5 1 18"></polyline>
<polyline points="17 6 23 6 23 12"></polyline>
</svg>
)}
</div>
<div className="task-actions"> <div className="task-actions">
<span className="task-completed-count">{task.completed}</span> <span className="task-completed-count">{task.completed}</span>
</div> </div>
@@ -408,10 +343,6 @@ function TaskList({ onNavigate, data, loading, backgroundLoading, onRefresh }) {
Добавить Добавить
</button> </button>
{loadingDetails && tasks.length > 0 && (
<div className="loading-details">Загрузка деталей задач...</div>
)}
{projectNames.length === 0 && !loading && tasks.length === 0 && ( {projectNames.length === 0 && !loading && tasks.length === 0 && (
<div className="empty-state"> <div className="empty-state">
<p>Задач пока нет. Добавьте задачу через кнопку "Добавить".</p> <p>Задач пока нет. Добавьте задачу через кнопку "Добавить".</p>

10
run.sh
View File

@@ -39,15 +39,19 @@ echo ""
# Проверяем, запущены ли контейнеры # Проверяем, запущены ли контейнеры
if docker-compose ps | grep -q "Up"; then if docker-compose ps | grep -q "Up"; then
echo -e "${YELLOW}Перезапуск существующих контейнеров...${NC}" echo -e "${YELLOW}Перезапуск существующих контейнеров...${NC}"
echo " - Backend сервер" echo " - Backend сервер (с пересборкой)"
echo " - Frontend приложение (с пересборкой)" echo " - Frontend приложение (с пересборкой)"
echo " - База данных" echo " - База данных"
# Пересобираем и перезапускаем веб-сервер с новыми изменениями # Пересобираем и перезапускаем веб-сервер с новыми изменениями
echo -e "${BLUE}Пересборка веб-приложения...${NC}" echo -e "${BLUE}Пересборка веб-приложения...${NC}"
docker-compose build play-life-web docker-compose build play-life-web
docker-compose up -d play-life-web docker-compose up -d play-life-web
# Перезапускаем остальные сервисы # Пересобираем и перезапускаем бэкенд с новыми изменениями
docker-compose restart backend db echo -e "${BLUE}Пересборка бэкенда...${NC}"
docker-compose build backend
docker-compose up -d --force-recreate backend
# Перезапускаем базу данных
docker-compose restart db
echo -e "${GREEN}✅ Контейнеры перезапущены${NC}" echo -e "${GREEN}✅ Контейнеры перезапущены${NC}"
else else
echo -e "${YELLOW}Запуск контейнеров...${NC}" echo -e "${YELLOW}Запуск контейнеров...${NC}"