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"`
|
||||
}
|
||||
|
||||
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" {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "play-life-web",
|
||||
"version": "4.11.0",
|
||||
"version": "4.12.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -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)
|
||||
@@ -413,6 +417,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({
|
||||
current: false,
|
||||
@@ -434,6 +478,7 @@ function AppContent() {
|
||||
current: null,
|
||||
full: null,
|
||||
tasks: null,
|
||||
todayEntries: null,
|
||||
})
|
||||
|
||||
// Обновляем ref при изменении данных
|
||||
@@ -449,8 +494,12 @@ function AppContent() {
|
||||
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}
|
||||
|
||||
@@ -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 <LoadingError onRetry={onRetry} />
|
||||
@@ -37,7 +131,51 @@ function FullStatistics({ selectedProject, onClearSelection, data, loading, erro
|
||||
<div className="text-gray-500 text-lg">Нет данных для отображения</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<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>
|
||||
)
|
||||
|
||||
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