From c8a47ff4080ea520f7acc5bc19b51b072615c019 Mon Sep 17 00:00:00 2001 From: poignatov Date: Fri, 13 Mar 2026 14:43:19 +0300 Subject: [PATCH] =?UTF-8?q?6.15.0:=20=D0=94=D1=80=D0=B0=D1=84=D1=82=D1=8B?= =?UTF-8?q?=20=D0=B0=D0=B2=D1=82=D0=BE-=D0=B2=D1=8B=D0=BF=D0=BE=D0=BB?= =?UTF-8?q?=D0=BD=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=B2=20=D1=81=D1=82=D0=B0?= =?UTF-8?q?=D1=82=D0=B8=D1=81=D1=82=D0=B8=D0=BA=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- VERSION | 2 +- play-life-backend/main.go | 134 ++++++++++ play-life-web/package.json | 2 +- .../src/components/TodayEntriesList.jsx | 231 ++++++++++++------ 4 files changed, 296 insertions(+), 73 deletions(-) diff --git a/VERSION b/VERSION index 2876b16..a3748c5 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -6.14.2 +6.15.0 diff --git a/play-life-backend/main.go b/play-life-backend/main.go index f3488a8..a43238b 100644 --- a/play-life-backend/main.go +++ b/play-life-backend/main.go @@ -244,6 +244,8 @@ type TodayEntry struct { Text string `json:"text"` CreatedDate string `json:"created_date"` Nodes []TodayEntryNode `json:"nodes"` + IsDraft bool `json:"is_draft"` + TaskID *int `json:"task_id,omitempty"` } type TodoistWebhook struct { @@ -3427,6 +3429,107 @@ func (a *App) getDraftPendingScores(userID int) (map[int]float64, error) { return scores, nil } +// getAutoCompleteDraftEntries возвращает драфты с auto_complete=true как TodayEntry для отображения в списке записей +func (a *App) getAutoCompleteDraftEntries(userID int) ([]TodayEntry, error) { + rows, err := a.DB.Query(` + SELECT td.task_id, t.name, COALESCE(t.reward_message, ''), td.progression_value, t.progression_base + FROM task_drafts td + JOIN tasks t ON td.task_id = t.id + WHERE td.user_id = $1 AND td.auto_complete = TRUE AND t.deleted = FALSE + ORDER BY td.updated_at DESC + `, userID) + if err != nil { + return nil, fmt.Errorf("error querying auto complete drafts: %w", err) + } + defer rows.Close() + + entries := make([]TodayEntry, 0) + + for rows.Next() { + var taskID int + var taskName string + var rewardMessageStr string + var progressionValue sql.NullFloat64 + var progressionBase sql.NullFloat64 + + if err := rows.Scan(&taskID, &taskName, &rewardMessageStr, &progressionValue, &progressionBase); err != nil { + log.Printf("Error scanning auto complete draft row: %v", err) + continue + } + + var progressionValuePtr *float64 + if progressionValue.Valid { + progressionValuePtr = &progressionValue.Float64 + } + var progressionBasePtr *float64 + if progressionBase.Valid { + progressionBasePtr = &progressionBase.Float64 + } + + // Получаем ноды (reward_configs) для задачи + rewardRows, err := a.DB.Query(` + SELECT rc.position, p.name AS project_name, rc.value, rc.use_progression + FROM reward_configs rc + JOIN projects p ON rc.project_id = p.id + WHERE rc.task_id = $1 + ORDER BY rc.position + `, taskID) + if err != nil { + log.Printf("Error querying rewards for draft task %d: %v", taskID, err) + continue + } + + nodes := make([]TodayEntryNode, 0) + + for rewardRows.Next() { + var position int + var projectName string + var rewardValue float64 + var useProgression bool + + if err := rewardRows.Scan(&position, &projectName, &rewardValue, &useProgression); err != nil { + log.Printf("Error scanning reward row for draft: %v", err) + continue + } + + reward := Reward{ + Value: rewardValue, + UseProgression: useProgression, + } + score := calculateRewardScore(reward, progressionValuePtr, progressionBasePtr) + + nodes = append(nodes, TodayEntryNode{ + ProjectName: projectName, + Score: score, + Index: position, + }) + } + rewardRows.Close() + + // Текст отдаём как есть (с плейсхолдерами $0, $1), форматирование делает фронтенд через formatEntryText + var entryText string + if rewardMessageStr != "" { + entryText = rewardMessageStr + } else { + entryText = taskName + } + + taskIDCopy := taskID + entries = append(entries, TodayEntry{ + IsDraft: true, + TaskID: &taskIDCopy, + Text: entryText, + Nodes: nodes, + }) + } + + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("error iterating auto complete draft rows: %w", err) + } + + return entries, nil +} + // getTodayScores получает сумму score всех нод, созданных сегодня для конкретного пользователя // Возвращает map[project_id]today_score для сегодняшнего дня func (a *App) getTodayScores(userID int) (map[int]float64, error) { @@ -7567,6 +7670,37 @@ func (a *App) getTodayEntriesHandler(w http.ResponseWriter, r *http.Request) { return } + // Если запрошена сегодняшняя дата — добавляем драфты с auto_complete=true в начало списка + tzStr := getEnv("TIMEZONE", "UTC") + tzLoc, tzErr := time.LoadLocation(tzStr) + if tzErr != nil { + tzLoc = time.UTC + } + todayStr := time.Now().In(tzLoc).Format("2006-01-02") + targetDateStr := targetDate.Format("2006-01-02") + + if targetDateStr == todayStr { + draftEntries, draftErr := a.getAutoCompleteDraftEntries(userID) + if draftErr != nil { + log.Printf("Error getting auto complete draft entries: %v", draftErr) + } else { + // Применяем фильтр по проекту если указан + if projectFilter != nil { + filtered := make([]TodayEntry, 0, len(draftEntries)) + for _, de := range draftEntries { + for _, node := range de.Nodes { + if node.ProjectName == *projectFilter { + filtered = append(filtered, de) + break + } + } + } + draftEntries = filtered + } + entries = append(draftEntries, entries...) + } + } + w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(entries) } diff --git a/play-life-web/package.json b/play-life-web/package.json index bbd27e7..e469d5d 100644 --- a/play-life-web/package.json +++ b/play-life-web/package.json @@ -1,6 +1,6 @@ { "name": "play-life-web", - "version": "6.14.2", + "version": "6.15.0", "type": "module", "scripts": { "dev": "vite", diff --git a/play-life-web/src/components/TodayEntriesList.jsx b/play-life-web/src/components/TodayEntriesList.jsx index 4881e73..dc4149d 100644 --- a/play-life-web/src/components/TodayEntriesList.jsx +++ b/play-life-web/src/components/TodayEntriesList.jsx @@ -5,10 +5,10 @@ import { useAuth } from './auth/AuthContext' // Функция для форматирования скорa (аналогично formatScore из TaskDetail) const formatScore = (num) => { if (num === 0) return '0' - + let str = num.toPrecision(4) str = str.replace(/\.?0+$/, '') - + if (str.includes('e+') || str.includes('e-')) { const numValue = parseFloat(str) if (Math.abs(numValue) >= 10000) { @@ -16,7 +16,7 @@ const formatScore = (num) => { } return numValue.toString().replace(/\.?0+$/, '') } - + return str } @@ -54,7 +54,7 @@ const formatEntryText = (text, nodes) => { const regex = new RegExp(placeholder.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g') if (nodesMap[i] && currentText.includes(placeholder)) { const node = nodesMap[i] - const scoreStr = node.score >= 0 + const scoreStr = node.score >= 0 ? `${node.project_name}+${formatScore(node.score)}` : `${node.project_name}-${formatScore(Math.abs(node.score))}` currentText = currentText.replace(regex, `__NODE_${i}__`) @@ -69,7 +69,7 @@ const formatEntryText = (text, nodes) => { for (let i = 99; i >= 0; i--) { if (nodesMap[i]) { const node = nodesMap[i] - const scoreStr = node.score >= 0 + const scoreStr = node.score >= 0 ? `${node.project_name}+${formatScore(node.score)}` : `${node.project_name}-${formatScore(Math.abs(node.score))}` const regex = new RegExp(`\\$${i}(?!\\d)`, 'g') @@ -85,12 +85,12 @@ const formatEntryText = (text, nodes) => { // Разбиваем текст на части и создаем React элементы const result = [] let searchIndex = 0 - + while (searchIndex < currentText.length) { // Ищем следующий маркер let foundMarker = null let markerIndex = currentText.length - + // Ищем все маркеры for (const marker in escapedMarkers) { const index = currentText.indexOf(marker, searchIndex) @@ -99,14 +99,14 @@ const formatEntryText = (text, nodes) => { foundMarker = marker } } - + // Если нашли маркер if (foundMarker) { // Добавляем текст до маркера if (markerIndex > searchIndex) { result.push(currentText.substring(searchIndex, markerIndex)) } - + // Добавляем элемент для маркера const markerData = escapedMarkers[foundMarker] if (markerData && markerData.type === 'node') { @@ -117,7 +117,7 @@ const formatEntryText = (text, nodes) => { // Это экранированный плейсхолдер result.push(markerData) } - + searchIndex = markerIndex + foundMarker.length } else { // Больше маркеров нет, добавляем оставшийся текст @@ -134,6 +134,7 @@ const formatEntryText = (text, nodes) => { function TodayEntriesList({ data, loading, error, onRetry, onDelete }) { const { authFetch } = useAuth() const [deletingIds, setDeletingIds] = useState(new Set()) + const [clearingAutoCompleteIds, setClearingAutoCompleteIds] = useState(new Set()) const handleDelete = async (entryId) => { if (deletingIds.has(entryId)) return @@ -174,6 +175,45 @@ function TodayEntriesList({ data, loading, error, onRetry, onDelete }) { } } + const handleClearAutoComplete = async (taskId) => { + if (clearingAutoCompleteIds.has(taskId)) return + + if (!window.confirm('Снять автовыполнение в конце дня? Задача не будет выполнена автоматически.')) { + return + } + + setClearingAutoCompleteIds(prev => new Set(prev).add(taskId)) + + try { + const response = await authFetch(`/api/tasks/${taskId}/draft`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ auto_complete: false }), + }) + + if (!response.ok) { + const errorText = await response.text() + console.error('Clear auto_complete error:', response.status, errorText) + throw new Error(`Ошибка: ${response.status}`) + } + + if (onDelete) { + onDelete() + } + } catch (err) { + console.error('Clear auto_complete failed:', err) + alert(err.message || 'Не удалось снять автовыполнение') + } finally { + setClearingAutoCompleteIds(prev => { + const next = new Set(prev) + next.delete(taskId) + return next + }) + } + } + if (loading) { return (
@@ -197,71 +237,120 @@ function TodayEntriesList({ data, loading, error, onRetry, onDelete }) { return (
- {data.map((entry) => ( -
- ) : ( - - - - - - - + )} - -
- {formatEntryText(entry.text, entry.nodes)} -
- {entry.created_date && ( -
- {new Date(entry.created_date).toLocaleTimeString('ru-RU', { - hour: '2-digit', - minute: '2-digit' - })} +
+ {formatEntryText(entry.text, entry.nodes)}
- )} -
- ))} +
+ {isDraft ? 'в конце дня' : ( + entry.created_date && new Date(entry.created_date).toLocaleTimeString('ru-RU', { + hour: '2-digit', + minute: '2-digit' + }) + )} +
+
+ ) + })}
)