Compare commits

...

5 Commits
v1.0.0 ... main

Author SHA1 Message Date
Primakov Alexandr Alexandrovich
19c50693e4 1.1.1 2025-11-17 20:21:20 +03:00
Primakov Alexandr Alexandrovich
0187796388 Add CigaretteLoader component for improved loading experience in Dashboard
- Introduced a new CigaretteLoader component to replace the default loading indicator in the Dashboard.
- Enhanced the loading experience with a custom animated SVG representation of a cigarette.
2025-11-17 20:21:11 +03:00
Primakov Alexandr Alexandrovich
56b475ef55 1.1.0 2025-11-17 20:11:32 +03:00
Primakov Alexandr Alexandrovich
06a5677324 Implement timeline chart for daily cigarette tracking
- Added a new timeline chart to visualize cigarette consumption throughout the day.
- Introduced average consumption calculations by hour using historical data.
- Implemented date selection for users to view specific day's statistics.
- Enhanced UI with responsive design elements and improved data presentation.
- Included markers for individual cigarette instances on the chart for better insights.
2025-11-17 20:11:20 +03:00
Primakov Alexandr Alexandrovich
4d7bd1a77e Update README.md to enhance feature descriptions and add test user information
- Renamed "Статистика" to "Дневная статистика" and added "Сводная статистика" section with detailed metrics and visualizations.
- Included a pre-configured test user for quick start and clarified registration requirements.
- Updated API endpoints section to reflect the new summary statistics endpoint.
- Enhanced the statistics page description with new features and metrics for user comparison.
2025-11-17 14:36:25 +03:00
6 changed files with 468 additions and 11 deletions

View File

@ -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
View File

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

View File

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

View 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>
)

View File

@ -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 = () => {

View File

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