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:
parent
4d7bd1a77e
commit
06a5677324
@ -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