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