Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
19c50693e4 | ||
|
|
0187796388 | ||
|
|
56b475ef55 | ||
|
|
06a5677324 | ||
|
|
4d7bd1a77e |
26
README.md
26
README.md
@ -11,7 +11,11 @@ Smoke Tracker - это приложение для самоконтроля и
|
|||||||
- **Регистрация и авторизация** - безопасная система входа с JWT токенами
|
- **Регистрация и авторизация** - безопасная система входа с JWT токенами
|
||||||
- **Учет сигарет** - быстрое добавление записей о выкуренных сигаретах
|
- **Учет сигарет** - быстрое добавление записей о выкуренных сигаретах
|
||||||
- **История** - просмотр последних записей с возможностью фильтрации
|
- **История** - просмотр последних записей с возможностью фильтрации
|
||||||
- **Статистика** - визуализация данных по дням с помощью графиков
|
- **Дневная статистика** - визуализация данных по дням с помощью графиков
|
||||||
|
- **Сводная статистика** - сравнение ваших показателей со средними значениями других пользователей
|
||||||
|
- График по дням недели
|
||||||
|
- Сравнение с другими пользователями
|
||||||
|
- Метрики активности
|
||||||
- **Адаптивный дизайн** - работает на всех устройствах
|
- **Адаптивный дизайн** - работает на всех устройствах
|
||||||
|
|
||||||
## 🛠 Технологии
|
## 🛠 Технологии
|
||||||
@ -123,9 +127,13 @@ smoke-tracker/
|
|||||||
|
|
||||||
### Тестовые данные для локальной разработки
|
### Тестовые данные для локальной разработки
|
||||||
|
|
||||||
Создайте пользователя через форму регистрации или используйте API напрямую.
|
Для быстрого старта доступен предустановленный тестовый пользователь:
|
||||||
|
- **Логин:** `testuser`
|
||||||
|
- **Пароль:** `test1234`
|
||||||
|
|
||||||
Минимальные требования:
|
Вы также можете создать нового пользователя через форму регистрации.
|
||||||
|
|
||||||
|
Минимальные требования для регистрации:
|
||||||
- Логин: минимум 3 символа
|
- Логин: минимум 3 символа
|
||||||
- Пароль: минимум 4 символа
|
- Пароль: минимум 4 символа
|
||||||
|
|
||||||
@ -140,6 +148,7 @@ smoke-tracker/
|
|||||||
- `POST /cigarettes/log` - Добавить запись о сигарете
|
- `POST /cigarettes/log` - Добавить запись о сигарете
|
||||||
- `GET /cigarettes` - Получить список записей
|
- `GET /cigarettes` - Получить список записей
|
||||||
- `GET /cigarettes/stats/daily` - Получить статистику по дням
|
- `GET /cigarettes/stats/daily` - Получить статистику по дням
|
||||||
|
- `GET /stats/summary` - Получить сводную статистику (индивидуальную и общую)
|
||||||
|
|
||||||
## 🎨 UI/UX
|
## 🎨 UI/UX
|
||||||
|
|
||||||
@ -163,9 +172,16 @@ smoke-tracker/
|
|||||||
- Фильтрация по датам
|
- Фильтрация по датам
|
||||||
|
|
||||||
#### Страница статистики
|
#### Страница статистики
|
||||||
- Интерактивный график (Recharts)
|
- Две вкладки: "Дневная статистика" и "Сводная статистика"
|
||||||
|
- **Дневная статистика:**
|
||||||
|
- Интерактивный линейный график по дням (Recharts)
|
||||||
- Выбор периода
|
- Выбор периода
|
||||||
- Сводная статистика
|
- Основные метрики (среднее, максимум, общее количество)
|
||||||
|
- **Сводная статистика:**
|
||||||
|
- Сравнение индивидуальных показателей с общими
|
||||||
|
- Метрики: дневное среднее (вы vs все), дней с данными, активных пользователей
|
||||||
|
- Столбчатая диаграмма по дням недели
|
||||||
|
- Таблица с детальной статистикой по дням недели
|
||||||
|
|
||||||
## 🧪 Разработка
|
## 🧪 Разработка
|
||||||
|
|
||||||
|
|||||||
4
package-lock.json
generated
4
package-lock.json
generated
@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "smoke-tracker",
|
"name": "smoke-tracker",
|
||||||
"version": "1.0.0",
|
"version": "1.1.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "smoke-tracker",
|
"name": "smoke-tracker",
|
||||||
"version": "1.0.0",
|
"version": "1.1.1",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@brojs/cli": "^1.10.0",
|
"@brojs/cli": "^1.10.0",
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "smoke-tracker",
|
"name": "smoke-tracker",
|
||||||
"version": "1.0.0",
|
"version": "1.1.1",
|
||||||
"description": "",
|
"description": "",
|
||||||
"main": "./src/index.tsx",
|
"main": "./src/index.tsx",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
162
src/components/CigaretteLoader.tsx
Normal file
162
src/components/CigaretteLoader.tsx
Normal file
@ -0,0 +1,162 @@
|
|||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
export const CigaretteLoader = () => (
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
height: '100vh',
|
||||||
|
backgroundColor: '#1a1a1a'
|
||||||
|
}}>
|
||||||
|
<svg width="200" height="60" viewBox="0 0 200 60" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<defs>
|
||||||
|
{/* Градиент для сигареты */}
|
||||||
|
<linearGradient id="cigaretteGradient" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||||
|
<stop offset="0%" style={{ stopColor: '#f5f5f5', stopOpacity: 1 }} />
|
||||||
|
<stop offset="95%" style={{ stopColor: '#e0e0e0', stopOpacity: 1 }} />
|
||||||
|
<stop offset="100%" style={{ stopColor: '#d4a574', stopOpacity: 1 }} />
|
||||||
|
</linearGradient>
|
||||||
|
|
||||||
|
{/* Градиент для тлеющей части */}
|
||||||
|
<linearGradient id="burningGradient" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||||
|
<stop offset="0%" style={{ stopColor: '#ff6b35', stopOpacity: 1 }} />
|
||||||
|
<stop offset="50%" style={{ stopColor: '#ff8c42', stopOpacity: 1 }} />
|
||||||
|
<stop offset="100%" style={{ stopColor: '#ffa600', stopOpacity: 1 }} />
|
||||||
|
</linearGradient>
|
||||||
|
|
||||||
|
{/* Маска для эффекта сгорания */}
|
||||||
|
<mask id="burnMask">
|
||||||
|
<rect x="0" y="0" width="200" height="60" fill="white">
|
||||||
|
<animate
|
||||||
|
attributeName="width"
|
||||||
|
from="0"
|
||||||
|
to="200"
|
||||||
|
dur="2s"
|
||||||
|
repeatCount="indefinite"
|
||||||
|
/>
|
||||||
|
</rect>
|
||||||
|
</mask>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
{/* Тело сигареты (не сгоревшая часть) */}
|
||||||
|
<rect x="0" y="22" width="180" height="16" rx="2" fill="url(#cigaretteGradient)" />
|
||||||
|
|
||||||
|
{/* Фильтр */}
|
||||||
|
<rect x="175" y="20" width="20" height="20" rx="2" fill="#d4a574" />
|
||||||
|
<line x1="180" y1="20" x2="180" y2="40" stroke="#b8956a" strokeWidth="1" />
|
||||||
|
<line x1="185" y1="20" x2="185" y2="40" stroke="#b8956a" strokeWidth="1" />
|
||||||
|
<line x1="190" y1="20" x2="190" y2="40" stroke="#b8956a" strokeWidth="1" />
|
||||||
|
|
||||||
|
{/* Сгоревшая часть с маской */}
|
||||||
|
<g mask="url(#burnMask)">
|
||||||
|
<rect x="0" y="22" width="180" height="16" fill="#333" opacity="0.8" />
|
||||||
|
|
||||||
|
{/* Тлеющий край */}
|
||||||
|
<rect x="0" y="24" width="5" height="12" fill="url(#burningGradient)">
|
||||||
|
<animate
|
||||||
|
attributeName="x"
|
||||||
|
from="0"
|
||||||
|
to="180"
|
||||||
|
dur="2s"
|
||||||
|
repeatCount="indefinite"
|
||||||
|
/>
|
||||||
|
</rect>
|
||||||
|
|
||||||
|
{/* Искры */}
|
||||||
|
<circle r="2" fill="#ff6b35">
|
||||||
|
<animate
|
||||||
|
attributeName="cx"
|
||||||
|
from="5"
|
||||||
|
to="185"
|
||||||
|
dur="2s"
|
||||||
|
repeatCount="indefinite"
|
||||||
|
/>
|
||||||
|
<animate
|
||||||
|
attributeName="cy"
|
||||||
|
values="20;18;20"
|
||||||
|
dur="0.3s"
|
||||||
|
repeatCount="indefinite"
|
||||||
|
/>
|
||||||
|
<animate
|
||||||
|
attributeName="opacity"
|
||||||
|
values="1;0.5;1"
|
||||||
|
dur="0.3s"
|
||||||
|
repeatCount="indefinite"
|
||||||
|
/>
|
||||||
|
</circle>
|
||||||
|
<circle r="1.5" fill="#ffa600">
|
||||||
|
<animate
|
||||||
|
attributeName="cx"
|
||||||
|
from="8"
|
||||||
|
to="188"
|
||||||
|
dur="2s"
|
||||||
|
repeatCount="indefinite"
|
||||||
|
/>
|
||||||
|
<animate
|
||||||
|
attributeName="cy"
|
||||||
|
values="40;42;40"
|
||||||
|
dur="0.4s"
|
||||||
|
repeatCount="indefinite"
|
||||||
|
/>
|
||||||
|
<animate
|
||||||
|
attributeName="opacity"
|
||||||
|
values="0.8;0.3;0.8"
|
||||||
|
dur="0.4s"
|
||||||
|
repeatCount="indefinite"
|
||||||
|
/>
|
||||||
|
</circle>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
{/* Дым */}
|
||||||
|
<g opacity="0.6">
|
||||||
|
<ellipse rx="3" ry="3" fill="#888">
|
||||||
|
<animate
|
||||||
|
attributeName="cx"
|
||||||
|
from="5"
|
||||||
|
to="185"
|
||||||
|
dur="2s"
|
||||||
|
repeatCount="indefinite"
|
||||||
|
/>
|
||||||
|
<animate
|
||||||
|
attributeName="cy"
|
||||||
|
from="18"
|
||||||
|
to="8"
|
||||||
|
dur="1s"
|
||||||
|
repeatCount="indefinite"
|
||||||
|
/>
|
||||||
|
<animate
|
||||||
|
attributeName="opacity"
|
||||||
|
from="0.6"
|
||||||
|
to="0"
|
||||||
|
dur="1s"
|
||||||
|
repeatCount="indefinite"
|
||||||
|
/>
|
||||||
|
</ellipse>
|
||||||
|
<ellipse rx="4" ry="4" fill="#999">
|
||||||
|
<animate
|
||||||
|
attributeName="cx"
|
||||||
|
from="3"
|
||||||
|
to="183"
|
||||||
|
dur="2s"
|
||||||
|
repeatCount="indefinite"
|
||||||
|
/>
|
||||||
|
<animate
|
||||||
|
attributeName="cy"
|
||||||
|
from="18"
|
||||||
|
to="5"
|
||||||
|
dur="1.2s"
|
||||||
|
repeatCount="indefinite"
|
||||||
|
/>
|
||||||
|
<animate
|
||||||
|
attributeName="opacity"
|
||||||
|
from="0.5"
|
||||||
|
to="0"
|
||||||
|
dur="1.2s"
|
||||||
|
repeatCount="indefinite"
|
||||||
|
/>
|
||||||
|
</ellipse>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
@ -7,9 +7,12 @@ import { SignInPage, SignUpPage } from './pages/auth'
|
|||||||
import { TrackerPage } from './pages/tracker'
|
import { TrackerPage } from './pages/tracker'
|
||||||
import { StatsPage } from './pages/stats'
|
import { StatsPage } from './pages/stats'
|
||||||
import { ProtectedRoute } from './components/ProtectedRoute'
|
import { ProtectedRoute } from './components/ProtectedRoute'
|
||||||
|
import { CigaretteLoader } from './components/CigaretteLoader'
|
||||||
|
|
||||||
const PageWrapper = ({ children }: React.PropsWithChildren) => (
|
const PageWrapper = ({ children }: React.PropsWithChildren) => (
|
||||||
<Suspense fallback={<div>Loading...</div>}>{children}</Suspense>
|
<Suspense fallback={<CigaretteLoader />}>
|
||||||
|
{children}
|
||||||
|
</Suspense>
|
||||||
)
|
)
|
||||||
|
|
||||||
export const Dashboard = () => {
|
export const Dashboard = () => {
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import React, { useState, useEffect } from 'react'
|
import React, { useState, useEffect, useMemo } from 'react'
|
||||||
import { useForm } from 'react-hook-form'
|
import { useForm } from 'react-hook-form'
|
||||||
import { zodResolver } from '@hookform/resolvers/zod'
|
import { zodResolver } from '@hookform/resolvers/zod'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
@ -14,7 +14,19 @@ import {
|
|||||||
Card,
|
Card,
|
||||||
Textarea,
|
Textarea,
|
||||||
Stack,
|
Stack,
|
||||||
|
Flex,
|
||||||
} from '@chakra-ui/react'
|
} from '@chakra-ui/react'
|
||||||
|
import {
|
||||||
|
LineChart,
|
||||||
|
Line,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
CartesianGrid,
|
||||||
|
Tooltip,
|
||||||
|
ResponsiveContainer,
|
||||||
|
ReferenceDot,
|
||||||
|
} from 'recharts'
|
||||||
|
import { format, startOfDay, endOfDay } from 'date-fns'
|
||||||
import { Field } from '../../components/ui/field'
|
import { Field } from '../../components/ui/field'
|
||||||
import { cigarettesApi } from '../../api/client'
|
import { cigarettesApi } from '../../api/client'
|
||||||
import { URLs } from '../../__data__/urls'
|
import { URLs } from '../../__data__/urls'
|
||||||
@ -32,6 +44,7 @@ export const TrackerPage: React.FC = () => {
|
|||||||
const [isLoading, setIsLoading] = useState(false)
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [success, setSuccess] = useState<string | null>(null)
|
const [success, setSuccess] = useState<string | null>(null)
|
||||||
|
const [selectedDate, setSelectedDate] = useState<string>(format(new Date(), 'yyyy-MM-dd'))
|
||||||
|
|
||||||
const {
|
const {
|
||||||
register,
|
register,
|
||||||
@ -113,6 +126,121 @@ export const TrackerPage: React.FC = () => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Calculate average consumption by hour from all historical data
|
||||||
|
const averageByHour = useMemo(() => {
|
||||||
|
// Group all cigarettes by date and hour
|
||||||
|
const dateHourMap: Record<string, Record<number, number>> = {}
|
||||||
|
|
||||||
|
cigarettes.forEach((cig) => {
|
||||||
|
const cigDate = new Date(cig.smokedAt)
|
||||||
|
const dateKey = format(cigDate, 'yyyy-MM-dd')
|
||||||
|
const hour = cigDate.getHours()
|
||||||
|
|
||||||
|
if (!dateHourMap[dateKey]) {
|
||||||
|
dateHourMap[dateKey] = {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count cumulative cigarettes up to this hour for this day
|
||||||
|
for (let h = hour; h <= 24; h++) {
|
||||||
|
dateHourMap[dateKey][h] = (dateHourMap[dateKey][h] || 0) + 1
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Calculate average for each hour
|
||||||
|
const hourAverages: Record<number, number> = {}
|
||||||
|
const daysCount = Object.keys(dateHourMap).length || 1
|
||||||
|
|
||||||
|
for (let hour = 8; hour <= 24; hour++) {
|
||||||
|
let totalForHour = 0
|
||||||
|
Object.values(dateHourMap).forEach((dayData) => {
|
||||||
|
totalForHour += dayData[hour] || 0
|
||||||
|
})
|
||||||
|
hourAverages[hour] = totalForHour / daysCount
|
||||||
|
}
|
||||||
|
|
||||||
|
return hourAverages
|
||||||
|
}, [cigarettes])
|
||||||
|
|
||||||
|
// Prepare timeline data for the selected date
|
||||||
|
const timelineData = useMemo(() => {
|
||||||
|
const targetDate = new Date(selectedDate)
|
||||||
|
const dayStart = startOfDay(targetDate)
|
||||||
|
const dayEnd = endOfDay(targetDate)
|
||||||
|
|
||||||
|
// Filter cigarettes for the selected date
|
||||||
|
const dayCigarettes = cigarettes.filter((cig) => {
|
||||||
|
const cigDate = new Date(cig.smokedAt)
|
||||||
|
return cigDate >= dayStart && cigDate <= dayEnd
|
||||||
|
})
|
||||||
|
|
||||||
|
// Sort by time
|
||||||
|
dayCigarettes.sort((a, b) => new Date(a.smokedAt).getTime() - new Date(b.smokedAt).getTime())
|
||||||
|
|
||||||
|
// Create timeline from 8:00 to 24:00
|
||||||
|
const timeline: Array<{
|
||||||
|
time: string
|
||||||
|
hour: number
|
||||||
|
minute: number
|
||||||
|
count: number
|
||||||
|
averageCount: number
|
||||||
|
cigarettes: Cigarette[]
|
||||||
|
}> = []
|
||||||
|
|
||||||
|
for (let hour = 8; hour <= 24; hour++) {
|
||||||
|
for (let minute = 0; minute < 60; minute += 30) {
|
||||||
|
if (hour === 24 && minute > 0) break // Stop at 24:00
|
||||||
|
|
||||||
|
const timeStr = `${hour.toString().padStart(2, '0')}:${minute.toString().padStart(2, '0')}`
|
||||||
|
const targetTime = new Date(targetDate)
|
||||||
|
targetTime.setHours(hour, minute, 0, 0)
|
||||||
|
|
||||||
|
// Count cigarettes up to this time
|
||||||
|
const cigsUpToNow = dayCigarettes.filter((cig) => {
|
||||||
|
const cigTime = new Date(cig.smokedAt)
|
||||||
|
return cigTime <= targetTime
|
||||||
|
})
|
||||||
|
|
||||||
|
// Get average count for this hour (interpolate for half hours)
|
||||||
|
const avgCount = minute === 0
|
||||||
|
? averageByHour[hour] || 0
|
||||||
|
: ((averageByHour[hour] || 0) + (averageByHour[hour + 1] || 0)) / 2
|
||||||
|
|
||||||
|
timeline.push({
|
||||||
|
time: timeStr,
|
||||||
|
hour,
|
||||||
|
minute,
|
||||||
|
count: cigsUpToNow.length,
|
||||||
|
averageCount: avgCount,
|
||||||
|
cigarettes: cigsUpToNow,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { timeline, dayCigarettes }
|
||||||
|
}, [cigarettes, selectedDate, averageByHour])
|
||||||
|
|
||||||
|
// Get cigarette markers for the chart
|
||||||
|
const cigaretteMarkers = useMemo(() => {
|
||||||
|
return timelineData.dayCigarettes.map((cig) => {
|
||||||
|
const cigDate = new Date(cig.smokedAt)
|
||||||
|
const hours = cigDate.getHours()
|
||||||
|
const minutes = cigDate.getMinutes()
|
||||||
|
const timeDecimal = hours + minutes / 60
|
||||||
|
|
||||||
|
// Find the count at this time
|
||||||
|
const count = timelineData.dayCigarettes.filter((c) => {
|
||||||
|
return new Date(c.smokedAt) <= cigDate
|
||||||
|
}).length
|
||||||
|
|
||||||
|
return {
|
||||||
|
time: format(cigDate, 'HH:mm'),
|
||||||
|
timeDecimal,
|
||||||
|
count,
|
||||||
|
note: cig.note,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}, [timelineData.dayCigarettes])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box minH="100vh" bg="gray.50" p={{ base: 2, md: 8 }}>
|
<Box minH="100vh" bg="gray.50" p={{ base: 2, md: 8 }}>
|
||||||
<VStack gap={6} maxW="4xl" mx="auto">
|
<VStack gap={6} maxW="4xl" mx="auto">
|
||||||
@ -217,6 +345,154 @@ export const TrackerPage: React.FC = () => {
|
|||||||
</Card.Root>
|
</Card.Root>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Timeline Chart */}
|
||||||
|
<Card.Root w="full">
|
||||||
|
<Card.Body p={{ base: 2, md: 6 }}>
|
||||||
|
<VStack gap={4} align="stretch">
|
||||||
|
<Flex justify="space-between" align="center" wrap="wrap" gap={2}>
|
||||||
|
<Heading size="md">График за день</Heading>
|
||||||
|
<HStack>
|
||||||
|
<Text fontSize="sm" fontWeight="medium">
|
||||||
|
Дата:
|
||||||
|
</Text>
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
value={selectedDate}
|
||||||
|
onChange={(e) => setSelectedDate(e.target.value)}
|
||||||
|
maxW="200px"
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
</HStack>
|
||||||
|
</Flex>
|
||||||
|
|
||||||
|
{timelineData.dayCigarettes.length === 0 ? (
|
||||||
|
<Box textAlign="center" py={8}>
|
||||||
|
<Text color="gray.500" fontSize="lg">
|
||||||
|
За выбранный день записей нет
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Box>
|
||||||
|
<Text fontSize="sm" color="gray.600" mb={2}>
|
||||||
|
Всего за день: <strong>{timelineData.dayCigarettes.length}</strong> {timelineData.dayCigarettes.length === 1 ? 'сигарета' : 'сигарет'}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box w="full" h={{ base: '300px', md: '400px' }}>
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<LineChart
|
||||||
|
data={timelineData.timeline}
|
||||||
|
margin={{ top: 5, right: 5, left: 0, bottom: 5 }}
|
||||||
|
>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" />
|
||||||
|
<XAxis
|
||||||
|
dataKey="time"
|
||||||
|
tick={{ fontSize: 12 }}
|
||||||
|
interval="preserveStartEnd"
|
||||||
|
tickFormatter={(value) => {
|
||||||
|
// Show only full hours
|
||||||
|
if (value.endsWith(':00')) {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
tick={{ fontSize: 12 }}
|
||||||
|
label={{
|
||||||
|
value: 'Количество',
|
||||||
|
angle: -90,
|
||||||
|
position: 'insideLeft',
|
||||||
|
style: { fontSize: 12 },
|
||||||
|
}}
|
||||||
|
allowDecimals={false}
|
||||||
|
/>
|
||||||
|
<Tooltip
|
||||||
|
content={({ active, payload }) => {
|
||||||
|
if (active && payload && payload.length) {
|
||||||
|
const data = payload[0].payload
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
bg="white"
|
||||||
|
p={3}
|
||||||
|
borderRadius="md"
|
||||||
|
borderWidth={1}
|
||||||
|
borderColor="gray.300"
|
||||||
|
boxShadow="md"
|
||||||
|
>
|
||||||
|
<Text fontSize="sm" fontWeight="bold" mb={1}>
|
||||||
|
{data.time}
|
||||||
|
</Text>
|
||||||
|
<Text fontSize="sm" color="blue.600">
|
||||||
|
Сегодня: {data.count}
|
||||||
|
</Text>
|
||||||
|
<Text fontSize="sm" color="gray.600">
|
||||||
|
Среднее: {data.averageCount.toFixed(1)}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{/* Average line */}
|
||||||
|
<Line
|
||||||
|
type="stepAfter"
|
||||||
|
dataKey="averageCount"
|
||||||
|
stroke="#A0AEC0"
|
||||||
|
strokeWidth={2}
|
||||||
|
strokeDasharray="5 5"
|
||||||
|
dot={false}
|
||||||
|
name="Среднее"
|
||||||
|
/>
|
||||||
|
{/* Current day line */}
|
||||||
|
<Line
|
||||||
|
type="stepAfter"
|
||||||
|
dataKey="count"
|
||||||
|
stroke="#3182ce"
|
||||||
|
strokeWidth={3}
|
||||||
|
dot={false}
|
||||||
|
activeDot={{ r: 6 }}
|
||||||
|
name="Сегодня"
|
||||||
|
/>
|
||||||
|
{/* Add markers for each cigarette */}
|
||||||
|
{cigaretteMarkers.map((marker, idx) => (
|
||||||
|
<ReferenceDot
|
||||||
|
key={idx}
|
||||||
|
x={marker.time}
|
||||||
|
y={marker.count}
|
||||||
|
r={5}
|
||||||
|
fill="#e53e3e"
|
||||||
|
stroke="#fff"
|
||||||
|
strokeWidth={2}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Legend */}
|
||||||
|
<HStack gap={4} justify="center" fontSize="sm" flexWrap="wrap">
|
||||||
|
<HStack gap={1}>
|
||||||
|
<Box w={4} h={0.5} bg="blue.500" />
|
||||||
|
<Text>Сегодня</Text>
|
||||||
|
</HStack>
|
||||||
|
<HStack gap={1}>
|
||||||
|
<Box w={4} h={0.5} bg="gray.400" style={{ borderTop: '2px dashed #A0AEC0' }} />
|
||||||
|
<Text>Среднее</Text>
|
||||||
|
</HStack>
|
||||||
|
<HStack gap={1}>
|
||||||
|
<Box w={3} h={3} bg="red.500" borderRadius="full" />
|
||||||
|
<Text>Момент курения</Text>
|
||||||
|
</HStack>
|
||||||
|
</HStack>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</VStack>
|
||||||
|
</Card.Body>
|
||||||
|
</Card.Root>
|
||||||
|
|
||||||
{/* Recent cigarettes list */}
|
{/* Recent cigarettes list */}
|
||||||
<Card.Root w="full">
|
<Card.Root w="full">
|
||||||
<Card.Body p={{ base: 4, md: 6 }}>
|
<Card.Body p={{ base: 4, md: 6 }}>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user