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.
This commit is contained in:
Primakov Alexandr Alexandrovich 2025-11-17 20:11:20 +03:00
parent 4d7bd1a77e
commit 06a5677324

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