v3.1.0: Оптимизация загрузки списка задач - все данные в одном запросе, добавлены индикаторы подзадач и прогрессии
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 40s
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 40s
This commit is contained in:
@@ -28,6 +28,7 @@ import (
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/joho/godotenv"
|
||||
_ "github.com/lib/pq"
|
||||
"github.com/lib/pq"
|
||||
"github.com/robfig/cron/v3"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
@@ -207,6 +208,10 @@ type Task struct {
|
||||
RewardMessage *string `json:"reward_message,omitempty"`
|
||||
ProgressionBase *float64 `json:"progression_base,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 {
|
||||
@@ -6248,13 +6253,40 @@ func (a *App) getTasksHandler(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Запрос с получением всех необходимых данных для группировки и отображения
|
||||
query := `
|
||||
SELECT id, name, completed, last_completed_at, repetition_period::text
|
||||
FROM tasks
|
||||
WHERE user_id = $1 AND parent_task_id IS NULL AND deleted = FALSE
|
||||
SELECT
|
||||
t.id,
|
||||
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
|
||||
CASE WHEN last_completed_at IS NULL OR last_completed_at::date < CURRENT_DATE THEN 0 ELSE 1 END,
|
||||
name
|
||||
CASE WHEN t.last_completed_at IS NULL OR t.last_completed_at::date < CURRENT_DATE THEN 0 ELSE 1 END,
|
||||
t.name
|
||||
`
|
||||
|
||||
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 lastCompletedAt 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 {
|
||||
log.Printf("Error scanning task: %v", err)
|
||||
continue
|
||||
@@ -6283,6 +6328,30 @@ func (a *App) getTasksHandler(w http.ResponseWriter, r *http.Request) {
|
||||
if repetitionPeriod.Valid {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -89,11 +89,31 @@
|
||||
color: #8b5cf6;
|
||||
}
|
||||
|
||||
.task-name-container {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.task-name {
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
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 {
|
||||
|
||||
@@ -10,8 +10,6 @@ function TaskList({ onNavigate, data, loading, backgroundLoading, onRefresh }) {
|
||||
const { authFetch } = useAuth()
|
||||
// Инициализируем tasks из 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 [isCompleting, setIsCompleting] = useState(false)
|
||||
const [expandedCompleted, setExpandedCompleted] = useState({})
|
||||
@@ -25,9 +23,6 @@ function TaskList({ onNavigate, data, loading, backgroundLoading, onRefresh }) {
|
||||
}
|
||||
})
|
||||
const [toast, setToast] = useState(null)
|
||||
|
||||
// Для отслеживания изменений в списке задач (чтобы не перезагружать детали без необходимости)
|
||||
const lastTaskIdsRef = useRef('')
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
@@ -38,64 +33,6 @@ function TaskList({ onNavigate, data, loading, backgroundLoading, onRefresh }) {
|
||||
// Загрузка данных управляется из App.jsx через loadTabData
|
||||
// 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) => {
|
||||
onNavigate?.('task-form', { taskId: task.id })
|
||||
}
|
||||
@@ -103,9 +40,8 @@ function TaskList({ onNavigate, data, loading, backgroundLoading, onRefresh }) {
|
||||
const handleCheckmarkClick = async (task, e) => {
|
||||
e.stopPropagation()
|
||||
|
||||
const detail = taskDetails[task.id]
|
||||
const hasProgression = detail?.task?.progression_base != null
|
||||
const hasSubtasks = detail?.subtasks && detail.subtasks.length > 0
|
||||
const hasProgression = task.has_progression || task.progression_base != null
|
||||
const hasSubtasks = task.subtasks_count > 0
|
||||
|
||||
if (hasProgression || hasSubtasks) {
|
||||
// Открываем экран details
|
||||
@@ -174,36 +110,12 @@ function TaskList({ onNavigate, data, loading, backgroundLoading, onRefresh }) {
|
||||
})
|
||||
}
|
||||
|
||||
// Получаем все проекты из наград задачи и подзадач
|
||||
// Получаем все проекты из задачи (теперь они приходят в task.project_names)
|
||||
const getTaskProjects = (task) => {
|
||||
const projects = new Set()
|
||||
const detail = taskDetails[task.id]
|
||||
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
if (task.project_names && Array.isArray(task.project_names)) {
|
||||
return task.project_names
|
||||
}
|
||||
|
||||
return Array.from(projects)
|
||||
return []
|
||||
}
|
||||
|
||||
// Функция для проверки, является ли период нулевым
|
||||
@@ -340,12 +252,11 @@ function TaskList({ onNavigate, data, loading, backgroundLoading, onRefresh }) {
|
||||
})
|
||||
|
||||
return groups
|
||||
}, [tasks, taskDetails])
|
||||
}, [tasks])
|
||||
|
||||
const renderTaskItem = (task) => {
|
||||
const detail = taskDetails[task.id]
|
||||
const hasProgression = detail?.task?.progression_base != null
|
||||
const hasSubtasks = detail?.subtasks && detail.subtasks.length > 0
|
||||
const hasProgression = task.has_progression || task.progression_base != null
|
||||
const hasSubtasks = task.subtasks_count > 0
|
||||
const showDetailOnCheckmark = hasProgression || hasSubtasks
|
||||
|
||||
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" />
|
||||
</svg>
|
||||
</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">
|
||||
<span className="task-completed-count">{task.completed}</span>
|
||||
</div>
|
||||
@@ -408,10 +343,6 @@ function TaskList({ onNavigate, data, loading, backgroundLoading, onRefresh }) {
|
||||
Добавить
|
||||
</button>
|
||||
|
||||
{loadingDetails && tasks.length > 0 && (
|
||||
<div className="loading-details">Загрузка деталей задач...</div>
|
||||
)}
|
||||
|
||||
{projectNames.length === 0 && !loading && tasks.length === 0 && (
|
||||
<div className="empty-state">
|
||||
<p>Задач пока нет. Добавьте задачу через кнопку "Добавить".</p>
|
||||
|
||||
10
run.sh
10
run.sh
@@ -39,15 +39,19 @@ echo ""
|
||||
# Проверяем, запущены ли контейнеры
|
||||
if docker-compose ps | grep -q "Up"; then
|
||||
echo -e "${YELLOW}Перезапуск существующих контейнеров...${NC}"
|
||||
echo " - Backend сервер"
|
||||
echo " - Backend сервер (с пересборкой)"
|
||||
echo " - Frontend приложение (с пересборкой)"
|
||||
echo " - База данных"
|
||||
# Пересобираем и перезапускаем веб-сервер с новыми изменениями
|
||||
echo -e "${BLUE}Пересборка веб-приложения...${NC}"
|
||||
docker-compose build 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}"
|
||||
else
|
||||
echo -e "${YELLOW}Запуск контейнеров...${NC}"
|
||||
|
||||
Reference in New Issue
Block a user