4.12.0: Добавлены записи за день на экране статистики
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m28s
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m28s
This commit is contained in:
@@ -177,6 +177,19 @@ type FullStatisticsItem struct {
|
|||||||
MaxGoalScore float64 `json:"max_goal_score"`
|
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 {
|
type TodoistWebhook struct {
|
||||||
EventName string `json:"event_name"`
|
EventName string `json:"event_name"`
|
||||||
EventData map[string]interface{} `json:"event_data"`
|
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/delete", app.deleteProjectHandler).Methods("POST", "OPTIONS")
|
||||||
protected.HandleFunc("/project/create", app.createProjectHandler).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("/d2dc349a-0d13-49b2-a8f0-1ab094bfba9b", app.getFullStatisticsHandler).Methods("GET", "OPTIONS")
|
||||||
|
protected.HandleFunc("/api/today-entries", app.getTodayEntriesHandler).Methods("GET", "OPTIONS")
|
||||||
|
|
||||||
// Integrations
|
// Integrations
|
||||||
protected.HandleFunc("/api/integrations/telegram", app.getTelegramIntegrationHandler).Methods("GET", "OPTIONS")
|
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)
|
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
|
// getTelegramIntegrationHandler возвращает текущую telegram интеграцию с deep link
|
||||||
func (a *App) getTelegramIntegrationHandler(w http.ResponseWriter, r *http.Request) {
|
func (a *App) getTelegramIntegrationHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method == "OPTIONS" {
|
if r.Method == "OPTIONS" {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "play-life-web",
|
"name": "play-life-web",
|
||||||
"version": "4.11.0",
|
"version": "4.12.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -100,24 +100,28 @@ function AppContent() {
|
|||||||
const [currentWeekData, setCurrentWeekData] = useState(null)
|
const [currentWeekData, setCurrentWeekData] = useState(null)
|
||||||
const [fullStatisticsData, setFullStatisticsData] = useState(null)
|
const [fullStatisticsData, setFullStatisticsData] = useState(null)
|
||||||
const [tasksData, setTasksData] = useState(null)
|
const [tasksData, setTasksData] = useState(null)
|
||||||
|
const [todayEntriesData, setTodayEntriesData] = useState(null)
|
||||||
|
|
||||||
// Состояния загрузки для каждого таба (показываются только при первой загрузке)
|
// Состояния загрузки для каждого таба (показываются только при первой загрузке)
|
||||||
const [currentWeekLoading, setCurrentWeekLoading] = useState(false)
|
const [currentWeekLoading, setCurrentWeekLoading] = useState(false)
|
||||||
const [fullStatisticsLoading, setFullStatisticsLoading] = useState(false)
|
const [fullStatisticsLoading, setFullStatisticsLoading] = useState(false)
|
||||||
const [prioritiesLoading, setPrioritiesLoading] = useState(false)
|
const [prioritiesLoading, setPrioritiesLoading] = useState(false)
|
||||||
const [tasksLoading, setTasksLoading] = useState(false)
|
const [tasksLoading, setTasksLoading] = useState(false)
|
||||||
|
const [todayEntriesLoading, setTodayEntriesLoading] = useState(false)
|
||||||
|
|
||||||
// Состояния фоновой загрузки (не показываются визуально)
|
// Состояния фоновой загрузки (не показываются визуально)
|
||||||
const [currentWeekBackgroundLoading, setCurrentWeekBackgroundLoading] = useState(false)
|
const [currentWeekBackgroundLoading, setCurrentWeekBackgroundLoading] = useState(false)
|
||||||
const [fullStatisticsBackgroundLoading, setFullStatisticsBackgroundLoading] = useState(false)
|
const [fullStatisticsBackgroundLoading, setFullStatisticsBackgroundLoading] = useState(false)
|
||||||
const [prioritiesBackgroundLoading, setPrioritiesBackgroundLoading] = useState(false)
|
const [prioritiesBackgroundLoading, setPrioritiesBackgroundLoading] = useState(false)
|
||||||
const [tasksBackgroundLoading, setTasksBackgroundLoading] = useState(false)
|
const [tasksBackgroundLoading, setTasksBackgroundLoading] = useState(false)
|
||||||
|
const [todayEntriesBackgroundLoading, setTodayEntriesBackgroundLoading] = useState(false)
|
||||||
|
|
||||||
// Ошибки
|
// Ошибки
|
||||||
const [currentWeekError, setCurrentWeekError] = useState(null)
|
const [currentWeekError, setCurrentWeekError] = useState(null)
|
||||||
const [fullStatisticsError, setFullStatisticsError] = useState(null)
|
const [fullStatisticsError, setFullStatisticsError] = useState(null)
|
||||||
const [prioritiesError, setPrioritiesError] = useState(null)
|
const [prioritiesError, setPrioritiesError] = useState(null)
|
||||||
const [tasksError, setTasksError] = useState(null)
|
const [tasksError, setTasksError] = useState(null)
|
||||||
|
const [todayEntriesError, setTodayEntriesError] = useState(null)
|
||||||
|
|
||||||
// Состояние для кнопки Refresh (если она есть)
|
// Состояние для кнопки Refresh (если она есть)
|
||||||
const [isRefreshing, setIsRefreshing] = useState(false)
|
const [isRefreshing, setIsRefreshing] = useState(false)
|
||||||
@@ -413,6 +417,46 @@ function AppContent() {
|
|||||||
}
|
}
|
||||||
}, [authFetch])
|
}, [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 для отслеживания инициализации табов (чтобы избежать лишних пересозданий функции)
|
// Используем ref для отслеживания инициализации табов (чтобы избежать лишних пересозданий функции)
|
||||||
const tabsInitializedRef = useRef({
|
const tabsInitializedRef = useRef({
|
||||||
current: false,
|
current: false,
|
||||||
@@ -434,6 +478,7 @@ function AppContent() {
|
|||||||
current: null,
|
current: null,
|
||||||
full: null,
|
full: null,
|
||||||
tasks: null,
|
tasks: null,
|
||||||
|
todayEntries: null,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Обновляем ref при изменении данных
|
// Обновляем ref при изменении данных
|
||||||
@@ -449,8 +494,12 @@ function AppContent() {
|
|||||||
cacheRef.current.tasks = tasksData
|
cacheRef.current.tasks = tasksData
|
||||||
}, [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') {
|
if (tab === 'current') {
|
||||||
const hasCache = cacheRef.current.current !== null
|
const hasCache = cacheRef.current.current !== null
|
||||||
const isInitialized = tabsInitializedRef.current.current
|
const isInitialized = tabsInitializedRef.current.current
|
||||||
@@ -472,11 +521,13 @@ function AppContent() {
|
|||||||
if (!isInitialized) {
|
if (!isInitialized) {
|
||||||
// Первая загрузка таба - загружаем с индикатором
|
// Первая загрузка таба - загружаем с индикатором
|
||||||
fetchFullStatisticsData(false)
|
fetchFullStatisticsData(false)
|
||||||
|
// todayEntries будет загружен в FullStatistics компоненте при выборе дня
|
||||||
tabsInitializedRef.current.full = true
|
tabsInitializedRef.current.full = true
|
||||||
setTabsInitialized(prev => ({ ...prev, full: true }))
|
setTabsInitialized(prev => ({ ...prev, full: true }))
|
||||||
} else if (hasCache && isBackground) {
|
} else if (hasCache && isBackground) {
|
||||||
// Возврат на таб с кешем - фоновая загрузка
|
// Возврат на таб с кешем - фоновая загрузка
|
||||||
fetchFullStatisticsData(true)
|
fetchFullStatisticsData(true)
|
||||||
|
// todayEntries будет загружен в FullStatistics компоненте при выборе дня
|
||||||
}
|
}
|
||||||
} else if (tab === 'priorities') {
|
} else if (tab === 'priorities') {
|
||||||
const isInitialized = tabsInitializedRef.current.priorities
|
const isInitialized = tabsInitializedRef.current.priorities
|
||||||
@@ -516,7 +567,7 @@ function AppContent() {
|
|||||||
fetchTasksData(true)
|
fetchTasksData(true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [fetchCurrentWeekData, fetchFullStatisticsData, fetchTasksData])
|
}, [fetchCurrentWeekData, fetchFullStatisticsData, fetchTasksData, fetchTodayEntries])
|
||||||
|
|
||||||
// Функция для обновления всех данных (для кнопки Refresh, если она есть)
|
// Функция для обновления всех данных (для кнопки Refresh, если она есть)
|
||||||
const refreshAllData = useCallback(async () => {
|
const refreshAllData = useCallback(async () => {
|
||||||
@@ -616,7 +667,8 @@ function AppContent() {
|
|||||||
const handleFocus = () => {
|
const handleFocus = () => {
|
||||||
if (document.visibilityState === 'visible') {
|
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) {
|
if (isFirstLoad) {
|
||||||
// Первая загрузка таба
|
// Первая загрузка таба
|
||||||
lastLoadedTabRef.current = tabKey
|
lastLoadedTabRef.current = tabKey
|
||||||
loadTabData(activeTab, false)
|
const projectName = activeTab === 'full' ? selectedProject : null
|
||||||
|
loadTabData(activeTab, false, projectName)
|
||||||
} else if (isReturningToTab) {
|
} else if (isReturningToTab) {
|
||||||
// Возврат на таб - фоновая загрузка
|
// Возврат на таб - фоновая загрузка
|
||||||
lastLoadedTabRef.current = tabKey
|
lastLoadedTabRef.current = tabKey
|
||||||
loadTabData(activeTab, true)
|
const projectName = activeTab === 'full' ? selectedProject : null
|
||||||
|
loadTabData(activeTab, true, projectName)
|
||||||
}
|
}
|
||||||
|
|
||||||
prevActiveTabRef.current = activeTab
|
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(() => {
|
useEffect(() => {
|
||||||
@@ -896,6 +960,11 @@ function AppContent() {
|
|||||||
data={fullStatisticsData}
|
data={fullStatisticsData}
|
||||||
loading={fullStatisticsLoading}
|
loading={fullStatisticsLoading}
|
||||||
error={fullStatisticsError}
|
error={fullStatisticsError}
|
||||||
|
todayEntries={todayEntriesData}
|
||||||
|
todayEntriesLoading={todayEntriesLoading || todayEntriesBackgroundLoading}
|
||||||
|
todayEntriesError={todayEntriesError}
|
||||||
|
onRetryTodayEntries={() => fetchTodayEntries(false, selectedProject, null)}
|
||||||
|
fetchTodayEntries={fetchTodayEntries}
|
||||||
onRetry={fetchFullStatisticsData}
|
onRetry={fetchFullStatisticsData}
|
||||||
currentWeekData={currentWeekData}
|
currentWeekData={currentWeekData}
|
||||||
onNavigate={handleNavigate}
|
onNavigate={handleNavigate}
|
||||||
|
|||||||
@@ -1,13 +1,107 @@
|
|||||||
import React from 'react'
|
import React, { useState, useEffect, useCallback } from 'react'
|
||||||
import WeekProgressChart from './WeekProgressChart'
|
import WeekProgressChart from './WeekProgressChart'
|
||||||
import LoadingError from './LoadingError'
|
import LoadingError from './LoadingError'
|
||||||
|
import TodayEntriesList from './TodayEntriesList'
|
||||||
import { getAllProjectsSorted } from '../utils/projectUtils'
|
import { getAllProjectsSorted } from '../utils/projectUtils'
|
||||||
import './Integrations.css'
|
import './Integrations.css'
|
||||||
|
|
||||||
// Экспортируем для обратной совместимости (если используется в других местах)
|
// Экспортируем для обратной совместимости (если используется в других местах)
|
||||||
export { getProjectColorByIndex } from '../utils/projectUtils'
|
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) {
|
if (error && (!data || data.length === 0) && !loading) {
|
||||||
return <LoadingError onRetry={onRetry} />
|
return <LoadingError onRetry={onRetry} />
|
||||||
@@ -37,7 +131,51 @@ function FullStatistics({ selectedProject, onClearSelection, data, loading, erro
|
|||||||
<div className="text-gray-500 text-lg">Нет данных для отображения</div>
|
<div className="text-gray-500 text-lg">Нет данных для отображения</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
<>
|
||||||
<WeekProgressChart data={data} allProjectsSorted={getAllProjectsSorted(data)} currentWeekData={currentWeekData} selectedProject={selectedProject} />
|
<WeekProgressChart data={data} allProjectsSorted={getAllProjectsSorted(data)} currentWeekData={currentWeekData} selectedProject={selectedProject} />
|
||||||
|
|
||||||
|
{/* Чипсы дней недели */}
|
||||||
|
{pastDays.length > 0 && (
|
||||||
|
<div className="mt-3 mb-2">
|
||||||
|
<div className="flex flex-wrap gap-2.5">
|
||||||
|
{pastDays.map((date, index) => {
|
||||||
|
const dateStr = formatDate(date)
|
||||||
|
const dayOfWeek = index + 1 // 1 = понедельник
|
||||||
|
const isSelected = selectedDate === dateStr
|
||||||
|
const isToday = dateStr === todayDateStr
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={dateStr}
|
||||||
|
onClick={() => handleDaySelect(date)}
|
||||||
|
className={`
|
||||||
|
h-9 px-4 rounded-lg text-sm font-semibold
|
||||||
|
transition-all duration-200 ease-in-out
|
||||||
|
flex items-center justify-center
|
||||||
|
${
|
||||||
|
isSelected
|
||||||
|
? 'bg-white text-gray-900 shadow-sm border border-gray-200'
|
||||||
|
: 'bg-transparent text-gray-700 border border-gray-300 hover:border-gray-400'
|
||||||
|
}
|
||||||
|
${isToday && !isSelected ? 'ring-2 ring-indigo-200 border-indigo-300' : ''}
|
||||||
|
active:scale-95
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
{dayNames[dayOfWeek - 1]}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<TodayEntriesList
|
||||||
|
data={todayEntries}
|
||||||
|
loading={todayEntriesLoading}
|
||||||
|
error={todayEntriesError}
|
||||||
|
onRetry={() => fetchTodayEntries && fetchTodayEntries(false, selectedProject, selectedDate)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
180
play-life-web/src/components/TodayEntriesList.jsx
Normal file
180
play-life-web/src/components/TodayEntriesList.jsx
Normal file
@@ -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(
|
||||||
|
<strong key={`node-${searchIndex}`}>{markerData.text}</strong>
|
||||||
|
)
|
||||||
|
} 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 (
|
||||||
|
<div className="flex justify-center items-center py-8">
|
||||||
|
<div className="w-8 h-8 border-4 border-indigo-200 border-t-indigo-600 rounded-full animate-spin"></div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return <LoadingError onRetry={onRetry} />
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data || data.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="text-center py-8 text-gray-500">
|
||||||
|
Нет записей за выбранный день
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-2 mb-6">
|
||||||
|
<div className="space-y-3">
|
||||||
|
{data.map((entry) => (
|
||||||
|
<div
|
||||||
|
key={entry.id}
|
||||||
|
className="bg-white rounded-lg p-4 shadow-sm border border-gray-200"
|
||||||
|
>
|
||||||
|
<div className="text-gray-800 whitespace-pre-wrap">
|
||||||
|
{formatEntryText(entry.text, entry.nodes)}
|
||||||
|
</div>
|
||||||
|
{entry.created_date && (
|
||||||
|
<div className="text-xs text-gray-500 mt-2">
|
||||||
|
{new Date(entry.created_date).toLocaleTimeString('ru-RU', {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TodayEntriesList
|
||||||
Reference in New Issue
Block a user