4.12.0: Добавлены записи за день на экране статистики
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m28s

This commit is contained in:
poignatov
2026-02-03 18:26:21 +03:00
parent c3d366b9c2
commit 36dd96976f
6 changed files with 534 additions and 11 deletions

View File

@@ -1 +1 @@
4.11.0
4.12.0

View File

@@ -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" {

View File

@@ -1,6 +1,6 @@
{
"name": "play-life-web",
"version": "4.11.0",
"version": "4.12.0",
"type": "module",
"scripts": {
"dev": "vite",

View File

@@ -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}

View File

@@ -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} />
<>
<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>
)

View 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