feat: добавлена функциональность откладывания задач (next_show_at)
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 41s

This commit is contained in:
poignatov
2026-01-06 15:56:52 +03:00
parent 1da35aaea4
commit 508355dcb3
6 changed files with 502 additions and 46 deletions

View File

@@ -1 +1 @@
3.1.5 3.2.0

View File

@@ -205,6 +205,7 @@ type Task struct {
Name string `json:"name"` Name string `json:"name"`
Completed int `json:"completed"` Completed int `json:"completed"`
LastCompletedAt *string `json:"last_completed_at,omitempty"` LastCompletedAt *string `json:"last_completed_at,omitempty"`
NextShowAt *string `json:"next_show_at,omitempty"`
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"`
@@ -261,6 +262,10 @@ type CompleteTaskRequest struct {
ChildrenTaskIDs []int `json:"children_task_ids,omitempty"` ChildrenTaskIDs []int `json:"children_task_ids,omitempty"`
} }
type PostponeTaskRequest struct {
NextShowAt *string `json:"next_show_at"`
}
// ============================================ // ============================================
// Auth types // Auth types
// ============================================ // ============================================
@@ -2916,6 +2921,11 @@ func (a *App) initPlayLifeDB() error {
log.Printf("Warning: Failed to apply migration 016 (add repetition_period): %v", err) log.Printf("Warning: Failed to apply migration 016 (add repetition_period): %v", err)
} }
// Apply migration 017: Add next_show_at to tasks
if _, err := a.DB.Exec("ALTER TABLE tasks ADD COLUMN IF NOT EXISTS next_show_at TIMESTAMP WITH TIME ZONE"); err != nil {
log.Printf("Warning: Failed to apply migration 017 (add next_show_at): %v", err)
}
// Создаем таблицу reward_configs // Создаем таблицу reward_configs
createRewardConfigsTable := ` createRewardConfigsTable := `
CREATE TABLE IF NOT EXISTS reward_configs ( CREATE TABLE IF NOT EXISTS reward_configs (
@@ -3598,6 +3608,7 @@ func main() {
protected.HandleFunc("/api/tasks/{id}", app.updateTaskHandler).Methods("PUT", "OPTIONS") protected.HandleFunc("/api/tasks/{id}", app.updateTaskHandler).Methods("PUT", "OPTIONS")
protected.HandleFunc("/api/tasks/{id}", app.deleteTaskHandler).Methods("DELETE", "OPTIONS") protected.HandleFunc("/api/tasks/{id}", app.deleteTaskHandler).Methods("DELETE", "OPTIONS")
protected.HandleFunc("/api/tasks/{id}/complete", app.completeTaskHandler).Methods("POST", "OPTIONS") protected.HandleFunc("/api/tasks/{id}/complete", app.completeTaskHandler).Methods("POST", "OPTIONS")
protected.HandleFunc("/api/tasks/{id}/postpone", app.postponeTaskHandler).Methods("POST", "OPTIONS")
// Admin operations // Admin operations
protected.HandleFunc("/admin/recreate-mv", app.recreateMaterializedViewHandler).Methods("POST", "OPTIONS") protected.HandleFunc("/admin/recreate-mv", app.recreateMaterializedViewHandler).Methods("POST", "OPTIONS")
@@ -6260,6 +6271,7 @@ func (a *App) getTasksHandler(w http.ResponseWriter, r *http.Request) {
t.name, t.name,
t.completed, t.completed,
t.last_completed_at, t.last_completed_at,
t.next_show_at,
t.repetition_period::text, t.repetition_period::text,
t.progression_base, t.progression_base,
COALESCE(( COALESCE((
@@ -6301,6 +6313,7 @@ func (a *App) getTasksHandler(w http.ResponseWriter, r *http.Request) {
for rows.Next() { for rows.Next() {
var task Task var task Task
var lastCompletedAt sql.NullString var lastCompletedAt sql.NullString
var nextShowAt sql.NullString
var repetitionPeriod sql.NullString var repetitionPeriod sql.NullString
var progressionBase sql.NullFloat64 var progressionBase sql.NullFloat64
var projectNames pq.StringArray var projectNames pq.StringArray
@@ -6310,7 +6323,8 @@ func (a *App) getTasksHandler(w http.ResponseWriter, r *http.Request) {
&task.ID, &task.ID,
&task.Name, &task.Name,
&task.Completed, &task.Completed,
&lastCompletedAt, &lastCompletedAt,
&nextShowAt,
&repetitionPeriod, &repetitionPeriod,
&progressionBase, &progressionBase,
&task.SubtasksCount, &task.SubtasksCount,
@@ -6325,6 +6339,9 @@ func (a *App) getTasksHandler(w http.ResponseWriter, r *http.Request) {
if lastCompletedAt.Valid { if lastCompletedAt.Valid {
task.LastCompletedAt = &lastCompletedAt.String task.LastCompletedAt = &lastCompletedAt.String
} }
if nextShowAt.Valid {
task.NextShowAt = &nextShowAt.String
}
if repetitionPeriod.Valid { if repetitionPeriod.Valid {
task.RepetitionPeriod = &repetitionPeriod.String task.RepetitionPeriod = &repetitionPeriod.String
} }
@@ -6387,17 +6404,18 @@ func (a *App) getTaskDetailHandler(w http.ResponseWriter, r *http.Request) {
var rewardMessage sql.NullString var rewardMessage sql.NullString
var progressionBase sql.NullFloat64 var progressionBase sql.NullFloat64
var lastCompletedAt sql.NullString var lastCompletedAt sql.NullString
var nextShowAt sql.NullString
var repetitionPeriod sql.NullString var repetitionPeriod sql.NullString
// Сначала получаем значение как строку напрямую, чтобы избежать проблем с NULL // Сначала получаем значение как строку напрямую, чтобы избежать проблем с NULL
var repetitionPeriodStr string var repetitionPeriodStr string
err = a.DB.QueryRow(` err = a.DB.QueryRow(`
SELECT id, name, completed, last_completed_at, reward_message, progression_base, SELECT id, name, completed, last_completed_at, next_show_at, reward_message, progression_base,
CASE WHEN repetition_period IS NULL THEN '' ELSE repetition_period::text END as repetition_period CASE WHEN repetition_period IS NULL THEN '' ELSE repetition_period::text END as repetition_period
FROM tasks FROM tasks
WHERE id = $1 AND user_id = $2 AND deleted = FALSE WHERE id = $1 AND user_id = $2 AND deleted = FALSE
`, taskID, userID).Scan( `, taskID, userID).Scan(
&task.ID, &task.Name, &task.Completed, &lastCompletedAt, &rewardMessage, &progressionBase, &repetitionPeriodStr, &task.ID, &task.Name, &task.Completed, &lastCompletedAt, &nextShowAt, &rewardMessage, &progressionBase, &repetitionPeriodStr,
) )
log.Printf("Scanned repetition_period for task %d: String='%s'", taskID, repetitionPeriodStr) log.Printf("Scanned repetition_period for task %d: String='%s'", taskID, repetitionPeriodStr)
@@ -6428,6 +6446,9 @@ func (a *App) getTaskDetailHandler(w http.ResponseWriter, r *http.Request) {
if lastCompletedAt.Valid { if lastCompletedAt.Valid {
task.LastCompletedAt = &lastCompletedAt.String task.LastCompletedAt = &lastCompletedAt.String
} }
if nextShowAt.Valid {
task.NextShowAt = &nextShowAt.String
}
if repetitionPeriod.Valid && repetitionPeriod.String != "" { if repetitionPeriod.Valid && repetitionPeriod.String != "" {
task.RepetitionPeriod = &repetitionPeriod.String task.RepetitionPeriod = &repetitionPeriod.String
log.Printf("Task %d has repetition_period: %s", task.ID, repetitionPeriod.String) log.Printf("Task %d has repetition_period: %s", task.ID, repetitionPeriod.String)
@@ -7460,21 +7481,21 @@ func (a *App) completeTaskHandler(w http.ResponseWriter, r *http.Request) {
// Задача никогда не будет переноситься в выполненные // Задача никогда не будет переноситься в выполненные
_, err = a.DB.Exec(` _, err = a.DB.Exec(`
UPDATE tasks UPDATE tasks
SET completed = completed + 1 SET completed = completed + 1, next_show_at = NULL
WHERE id = $1 WHERE id = $1
`, taskID) `, taskID)
} else { } else {
// Обычный период: обновляем счетчик и last_completed_at // Обычный период: обновляем счетчик и last_completed_at, сбрасываем next_show_at
_, err = a.DB.Exec(` _, err = a.DB.Exec(`
UPDATE tasks UPDATE tasks
SET completed = completed + 1, last_completed_at = NOW() SET completed = completed + 1, last_completed_at = NOW(), next_show_at = NULL
WHERE id = $1 WHERE id = $1
`, taskID) `, taskID)
} }
} else { } else {
_, err = a.DB.Exec(` _, err = a.DB.Exec(`
UPDATE tasks UPDATE tasks
SET completed = completed + 1, last_completed_at = NOW(), deleted = TRUE SET completed = completed + 1, last_completed_at = NOW(), next_show_at = NULL, deleted = TRUE
WHERE id = $1 WHERE id = $1
`, taskID) `, taskID)
} }
@@ -7514,6 +7535,82 @@ func (a *App) completeTaskHandler(w http.ResponseWriter, r *http.Request) {
}) })
} }
// postponeTaskHandler переносит задачу на указанную дату
func (a *App) postponeTaskHandler(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)
taskID, err := strconv.Atoi(vars["id"])
if err != nil {
sendErrorWithCORS(w, "Invalid task ID", http.StatusBadRequest)
return
}
var req PostponeTaskRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
log.Printf("Error decoding postpone task request: %v", err)
sendErrorWithCORS(w, "Invalid request body", http.StatusBadRequest)
return
}
// Проверяем владельца
var ownerID int
err = a.DB.QueryRow("SELECT user_id FROM tasks WHERE id = $1 AND deleted = FALSE", taskID).Scan(&ownerID)
if err == sql.ErrNoRows || ownerID != userID {
sendErrorWithCORS(w, "Task not found", http.StatusNotFound)
return
}
if err != nil {
log.Printf("Error checking task ownership: %v", err)
sendErrorWithCORS(w, fmt.Sprintf("Error checking task ownership: %v", err), http.StatusInternalServerError)
return
}
// Если NextShowAt == nil, устанавливаем next_show_at в NULL
// Иначе парсим дату и устанавливаем значение
var nextShowAtValue interface{}
if req.NextShowAt == nil || *req.NextShowAt == "" {
nextShowAtValue = nil
} else {
nextShowAt, err := time.Parse(time.RFC3339, *req.NextShowAt)
if err != nil {
log.Printf("Error parsing next_show_at: %v", err)
sendErrorWithCORS(w, "Invalid date format. Use RFC3339 format", http.StatusBadRequest)
return
}
nextShowAtValue = nextShowAt
}
// Обновляем next_show_at
_, err = a.DB.Exec(`
UPDATE tasks
SET next_show_at = $1
WHERE id = $2 AND user_id = $3
`, nextShowAtValue, taskID, userID)
if err != nil {
log.Printf("Error updating next_show_at: %v", err)
sendErrorWithCORS(w, fmt.Sprintf("Error updating next_show_at: %v", err), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"success": true,
"message": "Task postponed successfully",
})
}
// todoistDisconnectHandler отключает интеграцию Todoist // todoistDisconnectHandler отключает интеграцию Todoist
func (a *App) todoistDisconnectHandler(w http.ResponseWriter, r *http.Request) { func (a *App) todoistDisconnectHandler(w http.ResponseWriter, r *http.Request) {
if r.Method == "OPTIONS" { if r.Method == "OPTIONS" {

View File

@@ -0,0 +1,14 @@
-- Migration: Add next_show_at field to tasks table
-- This script adds the next_show_at field for postponing tasks
-- ============================================
-- Add next_show_at column
-- ============================================
ALTER TABLE tasks
ADD COLUMN IF NOT EXISTS next_show_at TIMESTAMP WITH TIME ZONE;
-- ============================================
-- Comments for documentation
-- ============================================
COMMENT ON COLUMN tasks.next_show_at IS 'Date when task should be shown again (NULL means use last_completed_at + period)';

View File

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

View File

@@ -96,6 +96,13 @@
gap: 0.5rem; gap: 0.5rem;
} }
.task-name-wrapper {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.125rem;
}
.task-name { .task-name {
font-size: 1rem; font-size: 1rem;
font-weight: 500; font-weight: 500;
@@ -105,6 +112,12 @@
gap: 0.25rem; gap: 0.25rem;
} }
.task-next-show-date {
font-size: 0.75rem;
color: #6b7280;
font-weight: 400;
}
.task-subtasks-count { .task-subtasks-count {
color: #9ca3af; color: #9ca3af;
font-size: 0.875rem; font-size: 0.875rem;
@@ -128,6 +141,156 @@
font-weight: 500; font-weight: 500;
} }
.task-postpone-button {
background: none;
border: none;
color: #6b7280;
cursor: pointer;
padding: 0.25rem;
border-radius: 0.25rem;
transition: all 0.2s;
display: flex;
align-items: center;
justify-content: center;
}
.task-postpone-button:hover {
background: #f3f4f6;
color: #6366f1;
}
.task-postpone-modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 50;
}
.task-postpone-modal {
background: white;
border-radius: 0.5rem;
max-width: 400px;
width: 90%;
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1);
}
.task-postpone-modal-header {
padding: 1.5rem;
border-bottom: 1px solid #e5e7eb;
display: flex;
justify-content: space-between;
align-items: center;
}
.task-postpone-modal-header h3 {
margin: 0;
font-size: 1.25rem;
font-weight: 600;
color: #1f2937;
}
.task-postpone-close-button {
background: none;
border: none;
font-size: 1.5rem;
color: #6b7280;
cursor: pointer;
padding: 0;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 0.25rem;
transition: all 0.2s;
}
.task-postpone-close-button:hover {
background: #f3f4f6;
color: #1f2937;
}
.task-postpone-modal-content {
padding: 1.5rem;
}
.task-postpone-task-name {
margin: 0 0 1rem 0;
font-size: 1rem;
color: #1f2937;
font-weight: 500;
}
.task-postpone-label {
display: flex;
flex-direction: column;
gap: 0.5rem;
font-size: 0.875rem;
font-weight: 500;
color: #374151;
}
.task-postpone-input {
padding: 0.75rem;
border: 1px solid #d1d5db;
border-radius: 0.375rem;
font-size: 1rem;
transition: all 0.2s;
}
.task-postpone-input:focus {
outline: none;
border-color: #6366f1;
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1);
}
.task-postpone-modal-actions {
padding: 1rem 1.5rem 1.5rem;
display: flex;
gap: 0.75rem;
justify-content: flex-end;
}
.task-postpone-cancel-button,
.task-postpone-submit-button {
padding: 0.75rem 1.5rem;
border: none;
border-radius: 0.375rem;
font-size: 1rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}
.task-postpone-cancel-button {
background: #f3f4f6;
color: #374151;
}
.task-postpone-cancel-button:hover:not(:disabled) {
background: #e5e7eb;
}
.task-postpone-submit-button {
background: #6366f1;
color: white;
}
.task-postpone-submit-button:hover:not(:disabled) {
background: #4f46e5;
}
.task-postpone-submit-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.task-menu-button { .task-menu-button {
background: none; background: none;
border: none; border: none;

View File

@@ -13,6 +13,9 @@ function TaskList({ onNavigate, data, loading, backgroundLoading, onRefresh }) {
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({})
const [selectedTaskForPostpone, setSelectedTaskForPostpone] = useState(null)
const [postponeDate, setPostponeDate] = useState('')
const [isPostponing, setIsPostponing] = useState(false)
// Загружаем состояние раскрытия "Бесконечные" из localStorage (по умолчанию true) // Загружаем состояние раскрытия "Бесконечные" из localStorage (по умолчанию true)
const [expandedInfinite, setExpandedInfinite] = useState(() => { const [expandedInfinite, setExpandedInfinite] = useState(() => {
try { try {
@@ -87,6 +90,94 @@ function TaskList({ onNavigate, data, loading, backgroundLoading, onRefresh }) {
onNavigate?.('task-form', { taskId: undefined }) onNavigate?.('task-form', { taskId: undefined })
} }
const handlePostponeClick = (task, e) => {
e.stopPropagation()
setSelectedTaskForPostpone(task)
// Устанавливаем дату по умолчанию - завтра
const tomorrow = new Date()
tomorrow.setDate(tomorrow.getDate() + 1)
tomorrow.setHours(0, 0, 0, 0)
setPostponeDate(tomorrow.toISOString().split('T')[0])
}
const handlePostponeSubmit = async () => {
if (!selectedTaskForPostpone || !postponeDate) return
setIsPostponing(true)
try {
// Преобразуем дату в ISO формат с временем
const dateObj = new Date(postponeDate)
dateObj.setHours(0, 0, 0, 0)
const isoDate = dateObj.toISOString()
const response = await authFetch(`${API_URL}/${selectedTaskForPostpone.id}/postpone`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ next_show_at: isoDate }),
})
if (!response.ok) {
const errorData = await response.json().catch(() => ({}))
throw new Error(errorData.message || 'Ошибка при переносе задачи')
}
// Обновляем список
if (onRefresh) {
onRefresh()
}
// Закрываем модальное окно
setSelectedTaskForPostpone(null)
setPostponeDate('')
} catch (err) {
console.error('Error postponing task:', err)
alert(err.message || 'Ошибка при переносе задачи')
} finally {
setIsPostponing(false)
}
}
const handlePostponeReset = async () => {
if (!selectedTaskForPostpone) return
setIsPostponing(true)
try {
const response = await authFetch(`${API_URL}/${selectedTaskForPostpone.id}/postpone`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ next_show_at: null }),
})
if (!response.ok) {
const errorData = await response.json().catch(() => ({}))
throw new Error(errorData.message || 'Ошибка при сбросе переноса задачи')
}
// Обновляем список
if (onRefresh) {
onRefresh()
}
// Закрываем модальное окно
setSelectedTaskForPostpone(null)
setPostponeDate('')
} catch (err) {
console.error('Error resetting postpone:', err)
alert(err.message || 'Ошибка при сбросе переноса задачи')
} finally {
setIsPostponing(false)
}
}
const handlePostponeClose = () => {
setSelectedTaskForPostpone(null)
setPostponeDate('')
}
const toggleCompletedExpanded = (projectName) => { const toggleCompletedExpanded = (projectName) => {
setExpandedCompleted(prev => ({ setExpandedCompleted(prev => ({
...prev, ...prev,
@@ -195,31 +286,42 @@ function TaskList({ onNavigate, data, loading, backgroundLoading, onRefresh }) {
let isCompleted = false let isCompleted = false
let isInfinite = false let isInfinite = false
// Если у задачи период повторения = 0, она в бесконечных // Если next_show_at установлен, задача всегда в выполненных (если дата в будущем)
if (task.repetition_period && isZeroPeriod(task.repetition_period)) { // даже если она бесконечная
if (task.next_show_at) {
const nextShowDate = new Date(task.next_show_at)
nextShowDate.setHours(0, 0, 0, 0)
isCompleted = nextShowDate.getTime() > today.getTime()
isInfinite = false
} else if (task.repetition_period && isZeroPeriod(task.repetition_period)) {
// Если у задачи период повторения = 0 и нет next_show_at, она в бесконечных
isInfinite = true isInfinite = true
isCompleted = false isCompleted = false
} else if (task.repetition_period) { } else if (task.repetition_period) {
// Если есть repetition_period (и он не 0), проверяем логику повторения // Если есть repetition_period (и он не 0), проверяем логику повторения
// Используем last_completed_at + period
let nextDueDate = null
if (task.last_completed_at) { if (task.last_completed_at) {
const lastCompleted = new Date(task.last_completed_at) const lastCompleted = new Date(task.last_completed_at)
const nextDueDate = addIntervalToDate(lastCompleted, task.repetition_period) nextDueDate = addIntervalToDate(lastCompleted, task.repetition_period)
}
if (nextDueDate) {
// Округляем до начала дня
nextDueDate.setHours(0, 0, 0, 0)
if (nextDueDate) { // Если nextDueDate > today, то задача в выполненных
// Округляем до начала дня isCompleted = nextDueDate.getTime() > today.getTime()
nextDueDate.setHours(0, 0, 0, 0) } else {
// Если не удалось определить дату, используем старую логику
// Если nextDueDate > today, то задача в выполненных if (task.last_completed_at) {
isCompleted = nextDueDate.getTime() > today.getTime()
} else {
// Если не удалось распарсить интервал, используем старую логику
const completedDate = new Date(task.last_completed_at) const completedDate = new Date(task.last_completed_at)
completedDate.setHours(0, 0, 0, 0) completedDate.setHours(0, 0, 0, 0)
isCompleted = completedDate.getTime() === today.getTime() isCompleted = completedDate.getTime() === today.getTime()
} else {
isCompleted = false
} }
} else {
// Если нет last_completed_at, то в обычной группе
isCompleted = false
} }
} else { } else {
// Если repetition_period == null, используем старую логику // Если repetition_period == null, используем старую логику
@@ -277,32 +379,66 @@ function TaskList({ onNavigate, data, loading, backgroundLoading, onRefresh }) {
</svg> </svg>
</div> </div>
<div className="task-name-container"> <div className="task-name-container">
<div className="task-name"> <div className="task-name-wrapper">
{task.name} <div className="task-name">
{hasSubtasks && ( {task.name}
<span className="task-subtasks-count">(+{task.subtasks_count})</span> {hasSubtasks && (
)} <span className="task-subtasks-count">(+{task.subtasks_count})</span>
)}
{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>
{task.next_show_at && (() => {
const showDate = new Date(task.next_show_at)
showDate.setHours(0, 0, 0, 0)
const today = new Date()
today.setHours(0, 0, 0, 0)
const tomorrow = new Date(today)
tomorrow.setDate(tomorrow.getDate() + 1)
let dateText
if (showDate.getTime() === today.getTime()) {
dateText = 'Сегодня'
} else if (showDate.getTime() === tomorrow.getTime()) {
dateText = 'Завтра'
} else {
dateText = showDate.toLocaleDateString('ru-RU', { day: 'numeric', month: 'long', year: 'numeric' })
}
return (
<div className="task-next-show-date">
{dateText}
</div>
)
})()}
</div> </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>
<div className="task-actions"> <div className="task-actions">
<span className="task-completed-count">{task.completed}</span> <button
className="task-postpone-button"
onClick={(e) => handlePostponeClick(task, e)}
title="Перенести задачу"
>
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="10" cy="10" r="8" stroke="currentColor" strokeWidth="1.5" fill="none"/>
<path d="M10 5V10L13 12" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" fill="none"/>
</svg>
</button>
</div> </div>
</div> </div>
</div> </div>
@@ -365,12 +501,14 @@ function TaskList({ onNavigate, data, loading, backgroundLoading, onRefresh }) {
<h3 className="project-group-title">{projectName}</h3> <h3 className="project-group-title">{projectName}</h3>
</div> </div>
{/* Обычные задачи */}
{group.notCompleted.length > 0 && ( {group.notCompleted.length > 0 && (
<div className="task-group"> <div className="task-group">
{group.notCompleted.map(renderTaskItem)} {group.notCompleted.map(renderTaskItem)}
</div> </div>
)} )}
{/* Бесконечные задачи */}
{hasInfinite && ( {hasInfinite && (
<div className="completed-section"> <div className="completed-section">
<button <button
@@ -390,6 +528,7 @@ function TaskList({ onNavigate, data, loading, backgroundLoading, onRefresh }) {
</div> </div>
)} )}
{/* Выполненные задачи */}
{hasCompleted && ( {hasCompleted && (
<div className="completed-section"> <div className="completed-section">
<button <button
@@ -425,6 +564,49 @@ function TaskList({ onNavigate, data, loading, backgroundLoading, onRefresh }) {
onTaskCompleted={() => setToast({ message: 'Задача выполнена' })} onTaskCompleted={() => setToast({ message: 'Задача выполнена' })}
/> />
)} )}
{/* Модальное окно для переноса задачи */}
{selectedTaskForPostpone && (
<div className="task-postpone-modal-overlay" onClick={handlePostponeClose}>
<div className="task-postpone-modal" onClick={(e) => e.stopPropagation()}>
<div className="task-postpone-modal-header">
<h3>Перенести задачу</h3>
<button onClick={handlePostponeClose} className="task-postpone-close-button">
</button>
</div>
<div className="task-postpone-modal-content">
<p className="task-postpone-task-name">{selectedTaskForPostpone.name}</p>
<label className="task-postpone-label">
Дата показа:
<input
type="date"
value={postponeDate}
onChange={(e) => setPostponeDate(e.target.value)}
className="task-postpone-input"
min={new Date().toISOString().split('T')[0]}
/>
</label>
</div>
<div className="task-postpone-modal-actions">
<button
onClick={handlePostponeReset}
className="task-postpone-cancel-button"
disabled={isPostponing}
>
{isPostponing ? 'Сброс...' : 'Сбросить'}
</button>
<button
onClick={handlePostponeSubmit}
className="task-postpone-submit-button"
disabled={isPostponing || !postponeDate}
>
{isPostponing ? 'Перенос...' : 'Перенести'}
</button>
</div>
</div>
</div>
)}
</div> </div>
) )
} }