From 36dd96976f622e7567c7c2d1480ffecfc86f47f3 Mon Sep 17 00:00:00 2001 From: poignatov Date: Tue, 3 Feb 2026 18:26:21 +0300 Subject: [PATCH] =?UTF-8?q?4.12.0:=20=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D1=8B=20=D0=B7=D0=B0=D0=BF=D0=B8=D1=81=D0=B8=20?= =?UTF-8?q?=D0=B7=D0=B0=20=D0=B4=D0=B5=D0=BD=D1=8C=20=D0=BD=D0=B0=20=D1=8D?= =?UTF-8?q?=D0=BA=D1=80=D0=B0=D0=BD=D0=B5=20=D1=81=D1=82=D0=B0=D1=82=D0=B8?= =?UTF-8?q?=D1=81=D1=82=D0=B8=D0=BA=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- VERSION | 2 +- play-life-backend/main.go | 136 +++++++++++++ play-life-web/package.json | 2 +- play-life-web/src/App.jsx | 81 +++++++- .../src/components/FullStatistics.jsx | 144 +++++++++++++- .../src/components/TodayEntriesList.jsx | 180 ++++++++++++++++++ 6 files changed, 534 insertions(+), 11 deletions(-) create mode 100644 play-life-web/src/components/TodayEntriesList.jsx diff --git a/VERSION b/VERSION index a162ea7..815588e 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -4.11.0 +4.12.0 diff --git a/play-life-backend/main.go b/play-life-backend/main.go index eb116b8..7080971 100644 --- a/play-life-backend/main.go +++ b/play-life-backend/main.go @@ -177,6 +177,19 @@ type FullStatisticsItem struct { MaxGoalScore float64 `json:"max_goal_score"` } +type TodayEntryNode struct { + ProjectName string `json:"project_name"` + Score float64 `json:"score"` + Index int `json:"index"` +} + +type TodayEntry struct { + ID int `json:"id"` + Text string `json:"text"` + CreatedDate string `json:"created_date"` + Nodes []TodayEntryNode `json:"nodes"` +} + type TodoistWebhook struct { EventName string `json:"event_name"` EventData map[string]interface{} `json:"event_data"` @@ -3740,6 +3753,7 @@ func main() { protected.HandleFunc("/project/delete", app.deleteProjectHandler).Methods("POST", "OPTIONS") protected.HandleFunc("/project/create", app.createProjectHandler).Methods("POST", "OPTIONS") protected.HandleFunc("/d2dc349a-0d13-49b2-a8f0-1ab094bfba9b", app.getFullStatisticsHandler).Methods("GET", "OPTIONS") + protected.HandleFunc("/api/today-entries", app.getTodayEntriesHandler).Methods("GET", "OPTIONS") // Integrations protected.HandleFunc("/api/integrations/telegram", app.getTelegramIntegrationHandler).Methods("GET", "OPTIONS") @@ -6185,6 +6199,128 @@ func (a *App) getFullStatisticsHandler(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(statistics) } +// getTodayEntriesHandler возвращает entries с nodes за сегодняшний день +func (a *App) getTodayEntriesHandler(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 + } + + // Получаем опциональные параметры из query string + projectName := r.URL.Query().Get("project") + var projectFilter *string + if projectName != "" { + projectFilter = &projectName + } + + // Получаем дату из query string (формат: YYYY-MM-DD), если не указана - используем сегодня + dateParam := r.URL.Query().Get("date") + var targetDate time.Time + if dateParam != "" { + parsedDate, err := time.Parse("2006-01-02", dateParam) + if err != nil { + log.Printf("Error parsing date parameter: %v", err) + sendErrorWithCORS(w, "Invalid date format. Use YYYY-MM-DD", http.StatusBadRequest) + return + } + targetDate = parsedDate + } else { + targetDate = time.Now() + } + + // Запрос для получения entries с nodes за указанный день + // Используем подзапрос для получения nodes с правильными индексами + query := ` + WITH entry_nodes AS ( + SELECT + e.id as entry_id, + e.text, + e.created_date, + p.name as project_name, + n.score, + ROW_NUMBER() OVER (PARTITION BY e.id ORDER BY n.id) - 1 as node_index + FROM entries e + JOIN nodes n ON n.entry_id = e.id + JOIN projects p ON n.project_id = p.id + WHERE DATE(n.created_date) = DATE($3) + AND e.user_id = $1 + AND n.user_id = $1 + AND p.user_id = $1 + AND p.deleted = FALSE + AND ($2::text IS NULL OR p.name = $2) + ) + SELECT + entry_id, + text, + created_date, + json_agg( + json_build_object( + 'project_name', project_name, + 'score', score, + 'index', node_index + ) ORDER BY node_index + ) as nodes + FROM entry_nodes + GROUP BY entry_id, text, created_date + ORDER BY created_date DESC + ` + + rows, err := a.DB.Query(query, userID, projectFilter, targetDate) + if err != nil { + log.Printf("Error querying today entries: %v", err) + sendErrorWithCORS(w, fmt.Sprintf("Error querying today entries: %v", err), http.StatusInternalServerError) + return + } + defer rows.Close() + + entries := make([]TodayEntry, 0) + + for rows.Next() { + var entry TodayEntry + var createdDate time.Time + var nodesJSON string + + err := rows.Scan( + &entry.ID, + &entry.Text, + &createdDate, + &nodesJSON, + ) + if err != nil { + log.Printf("Error scanning today entry row: %v", err) + continue + } + + // Парсим JSON с nodes + if err := json.Unmarshal([]byte(nodesJSON), &entry.Nodes); err != nil { + log.Printf("Error unmarshaling nodes JSON: %v", err) + continue + } + + // Форматируем дату в ISO 8601 + entry.CreatedDate = createdDate.Format(time.RFC3339) + + entries = append(entries, entry) + } + + if err := rows.Err(); err != nil { + log.Printf("Error iterating today entries rows: %v", err) + sendErrorWithCORS(w, fmt.Sprintf("Error iterating rows: %v", err), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(entries) +} + // getTelegramIntegrationHandler возвращает текущую telegram интеграцию с deep link func (a *App) getTelegramIntegrationHandler(w http.ResponseWriter, r *http.Request) { if r.Method == "OPTIONS" { diff --git a/play-life-web/package.json b/play-life-web/package.json index 23fea55..5cccd83 100644 --- a/play-life-web/package.json +++ b/play-life-web/package.json @@ -1,6 +1,6 @@ { "name": "play-life-web", - "version": "4.11.0", + "version": "4.12.0", "type": "module", "scripts": { "dev": "vite", diff --git a/play-life-web/src/App.jsx b/play-life-web/src/App.jsx index 237ad2e..e0a7dbc 100644 --- a/play-life-web/src/App.jsx +++ b/play-life-web/src/App.jsx @@ -100,24 +100,28 @@ function AppContent() { const [currentWeekData, setCurrentWeekData] = useState(null) const [fullStatisticsData, setFullStatisticsData] = useState(null) const [tasksData, setTasksData] = useState(null) + const [todayEntriesData, setTodayEntriesData] = useState(null) // Состояния загрузки для каждого таба (показываются только при первой загрузке) const [currentWeekLoading, setCurrentWeekLoading] = useState(false) const [fullStatisticsLoading, setFullStatisticsLoading] = useState(false) const [prioritiesLoading, setPrioritiesLoading] = useState(false) const [tasksLoading, setTasksLoading] = useState(false) + const [todayEntriesLoading, setTodayEntriesLoading] = useState(false) // Состояния фоновой загрузки (не показываются визуально) const [currentWeekBackgroundLoading, setCurrentWeekBackgroundLoading] = useState(false) const [fullStatisticsBackgroundLoading, setFullStatisticsBackgroundLoading] = useState(false) const [prioritiesBackgroundLoading, setPrioritiesBackgroundLoading] = useState(false) const [tasksBackgroundLoading, setTasksBackgroundLoading] = useState(false) + const [todayEntriesBackgroundLoading, setTodayEntriesBackgroundLoading] = useState(false) // Ошибки const [currentWeekError, setCurrentWeekError] = useState(null) const [fullStatisticsError, setFullStatisticsError] = useState(null) const [prioritiesError, setPrioritiesError] = useState(null) const [tasksError, setTasksError] = useState(null) + const [todayEntriesError, setTodayEntriesError] = useState(null) // Состояние для кнопки Refresh (если она есть) const [isRefreshing, setIsRefreshing] = useState(false) @@ -412,6 +416,46 @@ function AppContent() { } } }, [authFetch]) + + const fetchTodayEntries = useCallback(async (isBackground = false, projectName = null, date = null) => { + try { + if (isBackground) { + setTodayEntriesBackgroundLoading(true) + } else { + setTodayEntriesLoading(true) + } + setTodayEntriesError(null) + + // Формируем URL с опциональными параметрами project и date + let url = '/api/today-entries' + const params = [] + if (projectName) { + params.push(`project=${encodeURIComponent(projectName)}`) + } + if (date) { + params.push(`date=${encodeURIComponent(date)}`) + } + if (params.length > 0) { + url += `?${params.join('&')}` + } + + const response = await authFetch(url) + if (!response.ok) { + throw new Error('Ошибка загрузки данных') + } + const jsonData = await response.json() + setTodayEntriesData(Array.isArray(jsonData) ? jsonData : []) + } catch (err) { + setTodayEntriesError(err.message || 'Ошибка загрузки данных') + console.error('Ошибка загрузки today entries:', err) + } finally { + if (isBackground) { + setTodayEntriesBackgroundLoading(false) + } else { + setTodayEntriesLoading(false) + } + } + }, [authFetch]) // Используем ref для отслеживания инициализации табов (чтобы избежать лишних пересозданий функции) const tabsInitializedRef = useRef({ @@ -434,6 +478,7 @@ function AppContent() { current: null, full: null, tasks: null, + todayEntries: null, }) // Обновляем ref при изменении данных @@ -448,9 +493,13 @@ function AppContent() { useEffect(() => { cacheRef.current.tasks = tasksData }, [tasksData]) + + useEffect(() => { + cacheRef.current.todayEntries = todayEntriesData + }, [todayEntriesData]) // Функция для загрузки данных таба - const loadTabData = useCallback((tab, isBackground = false) => { + const loadTabData = useCallback((tab, isBackground = false, projectName = null) => { if (tab === 'current') { const hasCache = cacheRef.current.current !== null const isInitialized = tabsInitializedRef.current.current @@ -472,11 +521,13 @@ function AppContent() { if (!isInitialized) { // Первая загрузка таба - загружаем с индикатором fetchFullStatisticsData(false) + // todayEntries будет загружен в FullStatistics компоненте при выборе дня tabsInitializedRef.current.full = true setTabsInitialized(prev => ({ ...prev, full: true })) } else if (hasCache && isBackground) { // Возврат на таб с кешем - фоновая загрузка fetchFullStatisticsData(true) + // todayEntries будет загружен в FullStatistics компоненте при выборе дня } } else if (tab === 'priorities') { const isInitialized = tabsInitializedRef.current.priorities @@ -516,7 +567,7 @@ function AppContent() { fetchTasksData(true) } } - }, [fetchCurrentWeekData, fetchFullStatisticsData, fetchTasksData]) + }, [fetchCurrentWeekData, fetchFullStatisticsData, fetchTasksData, fetchTodayEntries]) // Функция для обновления всех данных (для кнопки Refresh, если она есть) const refreshAllData = useCallback(async () => { @@ -616,7 +667,8 @@ function AppContent() { const handleFocus = () => { if (document.visibilityState === 'visible') { // Загружаем данные активного таба фоново - loadTabData(activeTab, true) + const projectName = activeTab === 'full' ? selectedProject : null + loadTabData(activeTab, true, projectName) } } @@ -777,15 +829,27 @@ function AppContent() { if (isFirstLoad) { // Первая загрузка таба lastLoadedTabRef.current = tabKey - loadTabData(activeTab, false) + const projectName = activeTab === 'full' ? selectedProject : null + loadTabData(activeTab, false, projectName) } else if (isReturningToTab) { // Возврат на таб - фоновая загрузка lastLoadedTabRef.current = tabKey - loadTabData(activeTab, true) + const projectName = activeTab === 'full' ? selectedProject : null + loadTabData(activeTab, true, projectName) } prevActiveTabRef.current = activeTab - }, [activeTab, loadedTabs, loadTabData]) + }, [activeTab, loadedTabs, loadTabData, selectedProject]) + + // Обновляем todayEntries при изменении selectedProject для таба 'full' + // НЕ загружаем данные при открытии таба - это делает компонент FullStatistics + // Загружаем только при изменении selectedProject, если таб уже открыт + useEffect(() => { + if (activeTab === 'full' && prevActiveTabRef.current === 'full') { + // Таб уже был открыт, просто изменился selectedProject + // Данные будут загружены компонентом FullStatistics с правильной датой + } + }, [selectedProject, activeTab]) // Восстанавливаем позицию скролла при возвращении на таб useEffect(() => { @@ -896,6 +960,11 @@ function AppContent() { data={fullStatisticsData} loading={fullStatisticsLoading} error={fullStatisticsError} + todayEntries={todayEntriesData} + todayEntriesLoading={todayEntriesLoading || todayEntriesBackgroundLoading} + todayEntriesError={todayEntriesError} + onRetryTodayEntries={() => fetchTodayEntries(false, selectedProject, null)} + fetchTodayEntries={fetchTodayEntries} onRetry={fetchFullStatisticsData} currentWeekData={currentWeekData} onNavigate={handleNavigate} diff --git a/play-life-web/src/components/FullStatistics.jsx b/play-life-web/src/components/FullStatistics.jsx index fbc3444..48d5106 100644 --- a/play-life-web/src/components/FullStatistics.jsx +++ b/play-life-web/src/components/FullStatistics.jsx @@ -1,13 +1,107 @@ -import React from 'react' +import React, { useState, useEffect, useCallback } from 'react' import WeekProgressChart from './WeekProgressChart' import LoadingError from './LoadingError' +import TodayEntriesList from './TodayEntriesList' import { getAllProjectsSorted } from '../utils/projectUtils' import './Integrations.css' // Экспортируем для обратной совместимости (если используется в других местах) export { getProjectColorByIndex } from '../utils/projectUtils' -function FullStatistics({ selectedProject, onClearSelection, data, loading, error, onRetry, currentWeekData, onNavigate }) { +// Функция для получения дат текущей недели (понедельник - воскресенье) +const getCurrentWeekDates = () => { + const now = new Date() + const day = now.getDay() + // Вычисляем разницу до понедельника (1 = понедельник, 0 = воскресенье) + const diff = day === 0 ? -6 : 1 - day + const monday = new Date(now) + monday.setDate(now.getDate() + diff) + + const dates = [] + for (let i = 0; i < 7; i++) { + const date = new Date(monday) + date.setDate(monday.getDate() + i) + dates.push(date) + } + return dates +} + +// Функция для форматирования даты в YYYY-MM-DD +const formatDate = (date) => { + const year = date.getFullYear() + const month = String(date.getMonth() + 1).padStart(2, '0') + const day = String(date.getDate()).padStart(2, '0') + return `${year}-${month}-${day}` +} + +// Названия дней недели +const dayNames = ['пн', 'вт', 'ср', 'чт', 'пт', 'сб', 'вс'] + +function FullStatistics({ selectedProject, onClearSelection, data, loading, error, onRetry, currentWeekData, onNavigate, todayEntries, todayEntriesLoading, todayEntriesError, onRetryTodayEntries, fetchTodayEntries }) { + const [selectedDate, setSelectedDate] = useState(null) + const prevVisibleRef = React.useRef(false) + + // Получаем даты текущей недели + const weekDates = getCurrentWeekDates() + + // Определяем текущий день (используем useMemo для стабильности) + const today = React.useMemo(() => { + const date = new Date() + date.setHours(0, 0, 0, 0) + return date + }, []) + + // Получаем строковое представление сегодняшней даты + const todayDateStr = React.useMemo(() => formatDate(today), [today]) + + // Фильтруем только прошедшие дни (включая сегодня) + const pastDays = weekDates.filter((date) => { + const dateOnly = new Date(date) + dateOnly.setHours(0, 0, 0, 0) + return dateOnly <= today + }) + + // Отслеживаем, когда компонент становится видимым + const prevActiveTabRef = React.useRef(null) + + // Инициализируем выбранную дату текущим днем при первом рендере + // Также проверяем, что выбранная дата все еще в списке доступных дней + useEffect(() => { + const pastDaysDateStrs = pastDays.map(date => formatDate(date)) + + if (selectedDate === null) { + // Первая инициализация - устанавливаем текущий день + setSelectedDate(todayDateStr) + } else if (!pastDaysDateStrs.includes(selectedDate)) { + // Если выбранная дата больше не в списке доступных (например, прошла неделя) + // Сбрасываем на текущий день + setSelectedDate(todayDateStr) + } + }, [selectedDate, todayDateStr, pastDays]) + + // Отслеживаем открытие компонента и загружаем данные для selectedDate + useEffect(() => { + // Этот эффект срабатывает при каждом рендере, но мы проверяем, нужно ли загружать данные + if (selectedDate && fetchTodayEntries) { + // Всегда загружаем данные для selectedDate при его изменении + // Это гарантирует, что данные соответствуют выбранному чипсу + fetchTodayEntries(false, selectedProject, selectedDate) + } + }, [selectedDate, selectedProject, fetchTodayEntries]) + + // Загружаем данные при изменении selectedDate или selectedProject + useEffect(() => { + if (selectedDate && fetchTodayEntries) { + fetchTodayEntries(false, selectedProject, selectedDate) + } + }, [selectedDate, selectedProject, fetchTodayEntries]) + + // Обработчик выбора дня + const handleDaySelect = useCallback((date) => { + const dateStr = formatDate(date) + setSelectedDate(dateStr) + // Загрузка данных произойдет автоматически через useEffect выше + }, []) if (error && (!data || data.length === 0) && !loading) { return @@ -37,7 +131,51 @@ function FullStatistics({ selectedProject, onClearSelection, data, loading, erro
Нет данных для отображения
) : ( - + <> + + + {/* Чипсы дней недели */} + {pastDays.length > 0 && ( +
+
+ {pastDays.map((date, index) => { + const dateStr = formatDate(date) + const dayOfWeek = index + 1 // 1 = понедельник + const isSelected = selectedDate === dateStr + const isToday = dateStr === todayDateStr + + return ( + + ) + })} +
+
+ )} + + fetchTodayEntries && fetchTodayEntries(false, selectedProject, selectedDate)} + /> + )} ) diff --git a/play-life-web/src/components/TodayEntriesList.jsx b/play-life-web/src/components/TodayEntriesList.jsx new file mode 100644 index 0000000..9d42413 --- /dev/null +++ b/play-life-web/src/components/TodayEntriesList.jsx @@ -0,0 +1,180 @@ +import React from 'react' +import LoadingError from './LoadingError' + +// Функция для форматирования скор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) { + return str + } + return numValue.toString().replace(/\.?0+$/, '') + } + + return str +} + +// Функция для форматирования текста с заменой плейсхолдеров на nodes +const formatEntryText = (text, nodes) => { + if (!text || !nodes || nodes.length === 0) { + return text + } + + // Создаем map для быстрого доступа к nodes по индексу + const nodesMap = {} + nodes.forEach(node => { + nodesMap[node.index] = node + }) + + // Создаем массив для хранения частей текста и React элементов + const parts = [] + let lastIndex = 0 + let currentText = text + + // Сначала защищаем экранированные плейсхолдеры + const escapedMarkers = {} + for (let i = 0; i < 100; i++) { + const escaped = `\\$${i}` + const marker = `__ESCAPED_DOLLAR_${i}__` + if (currentText.includes(escaped)) { + escapedMarkers[marker] = escaped + currentText = currentText.replace(new RegExp(escaped.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), marker) + } + } + + // Заменяем ${0}, ${1}, и т.д. + for (let i = 0; i < 100; i++) { + const placeholder = `\${${i}}` + const regex = new RegExp(placeholder.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g') + if (nodesMap[i] && currentText.includes(placeholder)) { + const node = nodesMap[i] + 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}__`) + // Сохраняем информацию о замене + if (!escapedMarkers[`__NODE_${i}__`]) { + escapedMarkers[`__NODE_${i}__`] = { type: 'node', text: scoreStr } + } + } + } + + // Заменяем $0, $1, и т.д. (с конца, чтобы не заменить $1 в $10) + for (let i = 99; i >= 0; i--) { + if (nodesMap[i]) { + const node = nodesMap[i] + 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') + if (currentText.match(regex)) { + currentText = currentText.replace(regex, `__NODE_${i}__`) + if (!escapedMarkers[`__NODE_${i}__`]) { + escapedMarkers[`__NODE_${i}__`] = { type: 'node', text: scoreStr } + } + } + } + } + + // Разбиваем текст на части и создаем 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) + if (index !== -1 && index < markerIndex) { + markerIndex = index + foundMarker = marker + } + } + + // Если нашли маркер + if (foundMarker) { + // Добавляем текст до маркера + if (markerIndex > searchIndex) { + result.push(currentText.substring(searchIndex, markerIndex)) + } + + // Добавляем элемент для маркера + const markerData = escapedMarkers[foundMarker] + if (markerData && markerData.type === 'node') { + result.push( + {markerData.text} + ) + } else if (typeof markerData === 'string') { + // Это экранированный плейсхолдер + result.push(markerData) + } + + searchIndex = markerIndex + foundMarker.length + } else { + // Больше маркеров нет, добавляем оставшийся текст + if (searchIndex < currentText.length) { + result.push(currentText.substring(searchIndex)) + } + break + } + } + + return result.length > 0 ? result : currentText +} + +function TodayEntriesList({ data, loading, error, onRetry }) { + if (loading) { + return ( +
+
+
+ ) + } + + if (error) { + return + } + + if (!data || data.length === 0) { + return ( +
+ Нет записей за выбранный день +
+ ) + } + + 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' + })} +
+ )} +
+ ))} +
+
+ ) +} + +export default TodayEntriesList