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 токенами
|
||||
- **Учет сигарет** - быстрое добавление записей о выкуренных сигаретах
|
||||
- **История** - просмотр последних записей с возможностью фильтрации
|
||||
- **Статистика** - визуализация данных по дням с помощью графиков
|
||||
- **Дневная статистика** - визуализация данных по дням с помощью графиков
|
||||
- **Сводная статистика** - сравнение ваших показателей со средними значениями других пользователей
|
||||
- График по дням недели
|
||||
- Сравнение с другими пользователями
|
||||
- Метрики активности
|
||||
- **Адаптивный дизайн** - работает на всех устройствах
|
||||
|
||||
## 🛠 Технологии
|
||||
@ -123,9 +127,13 @@ smoke-tracker/
|
||||
|
||||
### Тестовые данные для локальной разработки
|
||||
|
||||
Создайте пользователя через форму регистрации или используйте API напрямую.
|
||||
Для быстрого старта доступен предустановленный тестовый пользователь:
|
||||
- **Логин:** `testuser`
|
||||
- **Пароль:** `test1234`
|
||||
|
||||
Минимальные требования:
|
||||
Вы также можете создать нового пользователя через форму регистрации.
|
||||
|
||||
Минимальные требования для регистрации:
|
||||
- Логин: минимум 3 символа
|
||||
- Пароль: минимум 4 символа
|
||||
|
||||
@ -140,6 +148,7 @@ smoke-tracker/
|
||||
- `POST /cigarettes/log` - Добавить запись о сигарете
|
||||
- `GET /cigarettes` - Получить список записей
|
||||
- `GET /cigarettes/stats/daily` - Получить статистику по дням
|
||||
- `GET /stats/summary` - Получить сводную статистику (индивидуальную и общую)
|
||||
|
||||
## 🎨 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",
|
||||
"version": "1.0.0",
|
||||
"version": "1.1.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "smoke-tracker",
|
||||
"version": "1.0.0",
|
||||
"version": "1.1.1",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@brojs/cli": "^1.10.0",
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "smoke-tracker",
|
||||
"version": "1.0.0",
|
||||
"version": "1.1.1",
|
||||
"description": "",
|
||||
"main": "./src/index.tsx",
|
||||
"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 { StatsPage } from './pages/stats'
|
||||
import { ProtectedRoute } from './components/ProtectedRoute'
|
||||
import { CigaretteLoader } from './components/CigaretteLoader'
|
||||
|
||||
const PageWrapper = ({ children }: React.PropsWithChildren) => (
|
||||
<Suspense fallback={<div>Loading...</div>}>{children}</Suspense>
|
||||
<Suspense fallback={<CigaretteLoader />}>
|
||||
{children}
|
||||
</Suspense>
|
||||
)
|
||||
|
||||
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 { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { z } from 'zod'
|
||||
@ -14,7 +14,19 @@ import {
|
||||
Card,
|
||||
Textarea,
|
||||
Stack,
|
||||
Flex,
|
||||
} 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 { cigarettesApi } from '../../api/client'
|
||||
import { URLs } from '../../__data__/urls'
|
||||
@ -32,6 +44,7 @@ export const TrackerPage: React.FC = () => {
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [success, setSuccess] = useState<string | null>(null)
|
||||
const [selectedDate, setSelectedDate] = useState<string>(format(new Date(), 'yyyy-MM-dd'))
|
||||
|
||||
const {
|
||||
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 (
|
||||
<Box minH="100vh" bg="gray.50" p={{ base: 2, md: 8 }}>
|
||||
<VStack gap={6} maxW="4xl" mx="auto">
|
||||
@ -217,6 +345,154 @@ export const TrackerPage: React.FC = () => {
|
||||
</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 */}
|
||||
<Card.Root w="full">
|
||||
<Card.Body p={{ base: 4, md: 6 }}>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user