Merge branch 'dev'

This commit is contained in:
Mo Tarbin
2025-07-20 10:41:42 -04:00
5 changed files with 345 additions and 133 deletions

View File

@@ -1,7 +1,7 @@
{ {
"name": "donetick", "name": "donetick",
"private": true, "private": true,
"version": "0.1.105", "version": "0.1.106",
"type": "module", "type": "module",
"engines": { "engines": {
"node": ">=20.0.0", "node": ">=20.0.0",

View File

@@ -0,0 +1,29 @@
import { useInfiniteQuery } from '@tanstack/react-query'
import { GetThingHistory } from '../utils/Fetcher'
export const useThingHistory = (thingId, limit = 10) => {
return useInfiniteQuery({
queryKey: ['thingHistory', thingId],
queryFn: async ({ pageParam = 0 }) => {
const response = await GetThingHistory(thingId, pageParam)
if (!response.ok) {
throw new Error('Failed to fetch thing history')
}
const data = await response.json()
return data
},
getNextPageParam: (lastPage, allPages) => {
// If the last page has fewer items than the limit, there are no more pages
if (lastPage.res.length < limit) {
return undefined
}
// Calculate the offset for the next page
const totalItems = allPages.reduce(
(acc, page) => acc + page.res.length,
0,
)
return totalItems
},
enabled: !!thingId, // Only run query if thingId exists
})
}

View File

@@ -29,7 +29,7 @@ const AuthenticationLoading = () => {
const getUserProfileAndNavigateToHome = () => { const getUserProfileAndNavigateToHome = () => {
GetUserProfile().then(data => { GetUserProfile().then(data => {
data.json().then(data => { data.json().then(data => {
refetchUserProfile.then(() => { refetchUserProfile().then(() => {
// check if redirect url is set in cookie: // check if redirect url is set in cookie:
const redirectUrl = Cookies.get('ca_redirect') const redirectUrl = Cookies.get('ca_redirect')
if (redirectUrl) { if (redirectUrl) {

View File

@@ -13,7 +13,6 @@ import {
Typography, Typography,
} from '@mui/joy' } from '@mui/joy'
import moment from 'moment' import moment from 'moment'
import { useEffect, useState } from 'react'
import { Link, useParams } from 'react-router-dom' import { Link, useParams } from 'react-router-dom'
import { import {
Line, Line,
@@ -23,40 +22,27 @@ import {
XAxis, XAxis,
YAxis, YAxis,
} from 'recharts' } from 'recharts'
import { GetThingHistory } from '../../utils/Fetcher' import { useTheme } from '@mui/joy/styles'
import { useThingHistory } from '../../queries/ThingQueries'
import LoadingComponent from '../components/Loading' import LoadingComponent from '../components/Loading'
const ThingsHistory = () => { const ThingsHistory = () => {
const { id } = useParams() const { id } = useParams()
const [thingsHistory, setThingsHistory] = useState([]) const theme = useTheme()
const [noMoreHistory, setNoMoreHistory] = useState(false) const {
const [errLoading, setErrLoading] = useState(false) data,
useEffect(() => { error,
GetThingHistory(id, 0, 10).then(resp => { isLoading,
if (resp.ok) { fetchNextPage,
resp.json().then(data => { hasNextPage,
setThingsHistory(data.res) isFetchingNextPage,
if (data.res.length < 10) { } = useThingHistory(id)
setNoMoreHistory(true)
} // Flatten all pages of history data
}) const thingsHistory = data?.pages.flatMap(page => page.res) || []
} else {
setErrLoading(true)
}
})
}, [id])
const handleLoadMore = () => { const handleLoadMore = () => {
GetThingHistory(id, thingsHistory.length).then(resp => { fetchNextPage()
if (resp.ok) {
resp.json().then(data => {
setThingsHistory([...thingsHistory, ...data.res])
if (data.res.length < 10) {
setNoMoreHistory(true)
}
})
}
})
} }
const formatTimeDifference = (startDate, endDate) => { const formatTimeDifference = (startDate, endDate) => {
@@ -79,11 +65,11 @@ const ThingsHistory = () => {
return `${timeValue} ${unit}${timeValue !== 1 ? 's' : ''}` return `${timeValue} ${unit}${timeValue !== 1 ? 's' : ''}`
} }
// if loading show loading spinner: // if loading show loading spinner:
if (thingsHistory.length === 0) { if (isLoading) {
return <LoadingComponent /> return <LoadingComponent />
} }
if (errLoading || !thingsHistory || thingsHistory.length === 0) { if (error || !thingsHistory || thingsHistory.length === 0) {
return ( return (
<Container <Container
maxWidth='md' maxWidth='md'
@@ -165,9 +151,17 @@ const ThingsHistory = () => {
<Line <Line
type='monotone' type='monotone'
dataKey='state' dataKey='state'
stroke='#8884d8' stroke={theme.palette.primary[500]}
activeDot={{ r: 8 }} activeDot={{
dot={{ r: 4 }} r: 8,
fill: theme.palette.primary[600],
stroke: theme.palette.primary[300],
}}
dot={{
r: 4,
fill: theme.palette.primary[500],
stroke: theme.palette.primary[300],
}}
/> />
</LineChart> </LineChart>
</ResponsiveContainer> </ResponsiveContainer>
@@ -305,9 +299,13 @@ const ThingsHistory = () => {
fullWidth fullWidth
color='primary' color='primary'
onClick={handleLoadMore} onClick={handleLoadMore}
disabled={noMoreHistory} disabled={!hasNextPage || isFetchingNextPage}
> >
{noMoreHistory ? 'No more history' : 'Load more'} {isFetchingNextPage
? 'Loading...'
: !hasNextPage
? 'No more history'
: 'Load more'}
</Button> </Button>
</Box> </Box>
</Container> </Container>

View File

@@ -1,7 +1,7 @@
import CancelIcon from '@mui/icons-material/Cancel' import CancelIcon from '@mui/icons-material/Cancel'
import CheckCircleIcon from '@mui/icons-material/CheckCircle' import CheckCircleIcon from '@mui/icons-material/CheckCircle'
import CircleIcon from '@mui/icons-material/Circle' import CircleIcon from '@mui/icons-material/Circle'
import { Cell, Legend, Pie, PieChart, Tooltip } from 'recharts' import { Cell, Pie, PieChart, Tooltip } from 'recharts'
import { EventBusy, Group, Toll } from '@mui/icons-material' import { EventBusy, Group, Toll } from '@mui/icons-material'
import { import {
@@ -131,44 +131,202 @@ const ChoreHistoryTimeline = ({ history }) => {
) )
} }
const renderPieChart = (data, size, isPrimary, chartType = null) => ( const renderPieChart = (data, size, isPrimary, chartType = null) => {
<PieChart width={size} height={size}> // Filter out items with zero or negative values
<Pie const validData = data.filter(item => item.value > 0)
data={data}
dataKey='value' if (validData.length === 0) {
nameKey='label' return (
cx='50%' <Box
cy='50%' sx={{
innerRadius={isPrimary ? size / 4 : size / 6} width: size,
paddingAngle={5} height: size,
cornerRadius={5} display: 'flex',
> alignItems: 'center',
{data.map((entry, index) => ( justifyContent: 'center',
<Cell key={`cell-${index}`} fill={entry.color} /> border: '1px dashed',
))} borderColor: 'divider',
</Pie> borderRadius: '8px',
{isPrimary && (
<Tooltip
formatter={(value, name, props) => {
if (chartType === 'tasksTime' && props.payload.count) {
return [`${value}h (${props.payload.count} times)`, name]
}
return [`${value}`, name]
}} }}
/> >
)} <Typography level='body-sm' color='neutral'>
{isPrimary && ( No data available
<Legend </Typography>
layout='horizontal' </Box>
verticalAlign='bottom' )
align='center' }
// format as : {entry.payload.label}: {value}
iconType='circle' // For primary charts, render chart and legend separately to control layout better
formatter={(label, value) => `${label}: ${value.payload.value}`} if (isPrimary) {
/> const chartSize = Math.min(size - 20, 220) // Reserve space and limit max size
)}
</PieChart> return (
) <Box
sx={{
width: size,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: validData.length <= 3 ? 1.5 : 2, // Smaller gap for fewer items
}}
>
{/* Chart Container */}
<Box
sx={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
}}
>
<PieChart width={chartSize} height={chartSize}>
<Pie
data={validData}
dataKey='value'
nameKey='label'
cx='50%'
cy='50%'
outerRadius={chartSize / 3}
innerRadius={chartSize / 8}
paddingAngle={validData.length > 1 ? 2 : 0}
cornerRadius={3}
minAngle={5}
>
{validData.map((entry, index) => (
<Cell key={`cell-${index}`} fill={entry.color} />
))}
</Pie>
<Tooltip
formatter={(value, name, props) => {
if (chartType === 'tasksTime' && props.payload.count) {
return [`${value}h (${props.payload.count} times)`, name]
}
return [`${value}`, name]
}}
/>
</PieChart>
</Box>
{/* Scrollable Legend Container */}
<Box
sx={{
width: '100%',
maxHeight: validData.length <= 3 ? 'auto' : '120px', // Dynamic height based on data count
minHeight: validData.length <= 3 ? 'auto' : '60px', // No minimum height for few items
overflowY: validData.length <= 3 ? 'visible' : 'auto', // No scroll for few items
overflowX: 'hidden',
px: 1,
'&::-webkit-scrollbar': {
width: '6px',
},
'&::-webkit-scrollbar-track': {
backgroundColor: 'neutral.100',
borderRadius: '3px',
},
'&::-webkit-scrollbar-thumb': {
backgroundColor: 'neutral.400',
borderRadius: '3px',
'&:hover': {
backgroundColor: 'neutral.500',
},
},
}}
>
<Box
sx={{
display: 'flex',
flexWrap: 'wrap',
gap: 0.5,
justifyContent: 'center',
alignItems: 'flex-start',
}}
>
{validData.map((entry, index) => (
<Chip
key={`legend-${index}`}
size='sm'
variant='soft'
sx={{
// backgroundColor: `${entry.color}20`, // 20% opacity
// borderColor: entry.color,
// border: '1px solid',
color: 'text.primary',
fontSize: '0.7rem',
py: 0.5,
px: 1,
maxWidth: '100%',
'&:hover': {
backgroundColor: `${entry.color}30`, // 30% opacity on hover
},
}}
startDecorator={
<Box
sx={{
width: 8,
height: 8,
backgroundColor: entry.color,
borderRadius: '50%',
flexShrink: 0,
}}
/>
}
>
<Typography
level='body-xs'
sx={{
fontSize: '0.7rem',
fontWeight: 500,
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
maxWidth: '150px',
}}
title={`${entry.label}: ${entry.value}${
chartType === 'tasksTime' && entry.count
? ` (${entry.count} times)`
: ''
}${
chartType === 'labelsDuration' || chartType === 'tasksTime'
? 'h'
: ''
}`}
>
{entry.label}: {entry.value}
{chartType === 'tasksTime' && entry.count
? ` (${entry.count}x)`
: ''}
{chartType === 'labelsDuration' || chartType === 'tasksTime'
? 'h'
: ''}
</Typography>
</Chip>
))}
</Box>
</Box>
</Box>
)
}
// For small preview charts, keep it simple without legend
return (
<PieChart width={size} height={size}>
<Pie
data={validData}
dataKey='value'
nameKey='label'
cx='50%'
cy='50%'
outerRadius={size / 3.5}
innerRadius={size / 8}
paddingAngle={validData.length > 1 ? 1 : 0}
cornerRadius={2}
>
{validData.map((entry, index) => (
<Cell key={`cell-${index}`} fill={entry.color} />
))}
</Pie>
</PieChart>
)
}
const USER_FILTER = (history, userId) => { const USER_FILTER = (history, userId) => {
if (userId === undefined || userId === 'all') return true if (userId === undefined || userId === 'all') return true
@@ -185,9 +343,6 @@ const UserActivites = () => {
const [historyPieChartData, setHistoryPieChartData] = React.useState([]) const [historyPieChartData, setHistoryPieChartData] = React.useState([])
const [choreDuePieChartData, setChoreDuePieChartData] = React.useState([]) const [choreDuePieChartData, setChoreDuePieChartData] = React.useState([])
const [choresAssignedChartData, setChoresAssignedChartData] = React.useState(
[],
)
const [choresPriorityChartData, setChoresPriorityChartData] = React.useState( const [choresPriorityChartData, setChoresPriorityChartData] = React.useState(
[], [],
) )
@@ -247,6 +402,13 @@ const UserActivites = () => {
// Generate tasks time chart data // Generate tasks time chart data
setTasksTimeChartData(generateTasksTimeChartData(filteredHistory)) setTasksTimeChartData(generateTasksTimeChartData(filteredHistory))
} else {
// Reset data when loading or no data
setEnrichedHistory([])
setSelectedHistory([])
setHistoryPieChartData([])
setChoresLabelsDurationChartData([])
setTasksTimeChartData([])
} }
}, [ }, [
isChoresHistoryLoading, isChoresHistoryLoading,
@@ -264,35 +426,6 @@ const UserActivites = () => {
? choresData.res ? choresData.res
: choresData.res.filter(chore => chore.assignedTo === selectedUser) : choresData.res.filter(chore => chore.assignedTo === selectedUser)
const generateChoreAssignedChartData = chores => {
var assignedToMe = 0
var assignedToOthers = 0
chores.forEach(chore => {
if (chore.assignedTo === userProfile?.id) {
assignedToMe++
} else assignedToOthers++
})
const group = []
if (assignedToMe > 0) {
group.push({
label: `Assigned to me`,
value: assignedToMe,
color: TASK_COLOR.ASSIGNED_TO_ME,
id: 1,
})
}
if (assignedToOthers > 0) {
group.push({
label: `Assigned to others`,
value: assignedToOthers,
color: TASK_COLOR.ASSIGNED_TO_OTHERS,
id: 2,
})
}
return group
}
const generateChorePriorityPieChartData = chores => { const generateChorePriorityPieChartData = chores => {
const groups = ChoresGrouper('priority', chores, null) const groups = ChoresGrouper('priority', chores, null)
return groups return groups
@@ -400,7 +533,6 @@ const UserActivites = () => {
const choreDuePieChartData = generateChoreDuePieChartData(filteredChores) const choreDuePieChartData = generateChoreDuePieChartData(filteredChores)
setChoreDuePieChartData(choreDuePieChartData) setChoreDuePieChartData(choreDuePieChartData)
setChoresAssignedChartData(generateChoreAssignedChartData(filteredChores))
setChoresPriorityChartData( setChoresPriorityChartData(
generateChorePriorityPieChartData(filteredChores), generateChorePriorityPieChartData(filteredChores),
) )
@@ -412,6 +544,10 @@ const UserActivites = () => {
}, [isChoresLoading, choresData, userProfile?.id, circleUsers, selectedUser]) }, [isChoresLoading, choresData, userProfile?.id, circleUsers, selectedUser])
const generateChoreLabelsWithDurationChartData = (chores, history) => { const generateChoreLabelsWithDurationChartData = (chores, history) => {
if (!chores || !history || chores.length === 0 || history.length === 0) {
return []
}
const labelDurations = {} const labelDurations = {}
let unlabeledDuration = 0 let unlabeledDuration = 0
@@ -467,6 +603,10 @@ const UserActivites = () => {
} }
const generateTasksTimeChartData = history => { const generateTasksTimeChartData = history => {
if (!history || history.length === 0) {
return []
}
const taskDurations = {} const taskDurations = {}
const colorValues = Object.values(COLORS) const colorValues = Object.values(COLORS)
@@ -504,6 +644,10 @@ const UserActivites = () => {
} }
const generateChoreDuePieChartData = chores => { const generateChoreDuePieChartData = chores => {
if (!chores || chores.length === 0) {
return []
}
const groups = ChoresGrouper('due_date', chores, null) const groups = ChoresGrouper('due_date', chores, null)
return groups return groups
.map(group => { .map(group => {
@@ -518,44 +662,58 @@ const UserActivites = () => {
} }
const generateHistoryPieChartData = history => { const generateHistoryPieChartData = history => {
if (!history || history.length === 0) {
return []
}
const totalCompleted = const totalCompleted =
history.filter(item => item.dueDate > item.performedAt).length || 0 history.filter(item => item.dueDate > item.performedAt).length || 0
const totalLate = const totalLate =
history.filter(item => item.dueDate < item.performedAt).length || 0 history.filter(item => item.dueDate < item.performedAt).length || 0
const totalNoDueDate = history.filter(item => !item.dueDate).length || 0 const totalNoDueDate = history.filter(item => !item.dueDate).length || 0
return [ const result = []
{
if (totalCompleted > 0) {
result.push({
label: `On time`, label: `On time`,
value: totalCompleted, value: totalCompleted,
color: TASK_COLOR.COMPLETED, color: TASK_COLOR.COMPLETED,
id: 1, id: 1,
}, })
{ }
if (totalLate > 0) {
result.push({
label: `Late`, label: `Late`,
value: totalLate, value: totalLate,
color: TASK_COLOR.LATE, color: TASK_COLOR.LATE,
id: 2, id: 2,
}, })
{ }
if (totalNoDueDate > 0) {
result.push({
label: `Completed`, label: `Completed`,
value: totalNoDueDate, value: totalNoDueDate,
color: TASK_COLOR.ANYTIME, color: TASK_COLOR.ANYTIME,
id: 3, id: 3,
}, })
] }
return result
} }
if (isChoresHistoryLoading || isChoresLoading) { if (isChoresHistoryLoading || isChoresLoading) {
return <LoadingComponent /> return <LoadingComponent />
} }
const chartData = { const chartData = {
history: { history: {
data: historyPieChartData, data: historyPieChartData || [],
title: 'Status', title: 'Status',
description: 'Completed tasks status', description: 'Completed tasks status',
}, },
due: { due: {
data: choreDuePieChartData, data: choreDuePieChartData || [],
title: 'Due Date', title: 'Due Date',
description: 'Current tasks due date', description: 'Current tasks due date',
}, },
@@ -565,27 +723,27 @@ const UserActivites = () => {
// description: 'Tasks assigned to you vs others', // description: 'Tasks assigned to you vs others',
// }, // },
priority: { priority: {
data: choresPriorityChartData, data: choresPriorityChartData || [],
title: 'Priority', title: 'Priority',
description: 'Tasks by priority', description: 'Tasks by priority',
}, },
labels: { labels: {
data: choresLabelsChartData, data: choresLabelsChartData || [],
title: 'Labels', title: 'Labels',
description: 'Tasks by labels', description: 'Tasks by labels',
}, },
labelsDuration: { labelsDuration: {
data: choresLabelsDurationChartData, data: choresLabelsDurationChartData || [],
title: 'Labels (time)', title: 'Labels (time)',
description: 'Time spent by labels (hours)', description: 'Time spent by labels (hours)',
}, },
tasksTime: { tasksTime: {
data: tasksTimeChartData, data: tasksTimeChartData || [],
title: 'Tasks (time)', title: 'Tasks (time)',
description: 'Time spent by individual tasks (hours)', description: 'Time spent by individual tasks (hours)',
}, },
assigneeBreakdown: { assigneeBreakdown: {
data: choresAssigneeBreakdownChartData, data: choresAssigneeBreakdownChartData || [],
title: 'by Assignee', title: 'by Assignee',
description: 'Tasks grouped by assignee', description: 'Tasks grouped by assignee',
}, },
@@ -629,7 +787,7 @@ const UserActivites = () => {
return ( return (
<Container <Container
maxWidth='xl' maxWidth='lg'
sx={{ sx={{
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
@@ -871,7 +1029,7 @@ const UserActivites = () => {
sx={{ sx={{
width: { xs: '100%', lg: '350px' }, width: { xs: '100%', lg: '350px' },
position: { xs: 'static', lg: 'sticky' }, position: { xs: 'static', lg: 'sticky' },
top: { lg: '20px' }, top: { lg: '60px' },
alignSelf: { lg: 'flex-start' }, alignSelf: { lg: 'flex-start' },
maxHeight: { lg: 'calc(100vh - 40px)' }, maxHeight: { lg: 'calc(100vh - 40px)' },
overflowY: { lg: 'auto' }, overflowY: { lg: 'auto' },
@@ -880,12 +1038,26 @@ const UserActivites = () => {
> >
{/* Charts Container */} {/* Charts Container */}
<Card <Card
variant='outlined' variant='plain'
sx={{ sx={{
// maxHeight: { lg: '90vh' },
p: 2, p: 2,
borderRadius: 12, display: 'flex',
backdropFilter: 'blur(10px)', flexDirection: 'column',
alignItems: 'center',
mr: 10,
justifyContent: 'space-between',
boxShadow: 'sm',
borderRadius: 20,
width: '315px',
mb: 1,
}} }}
// variant='outlined'
// sx={{
// p: 2,
// borderRadius: 12,
// backdropFilter: 'blur(10px)',
// }}
> >
<Stack spacing={3}> <Stack spacing={3}>
{/* Main Chart */} {/* Main Chart */}
@@ -894,9 +1066,20 @@ const UserActivites = () => {
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'flex-start',
textAlign: 'center', textAlign: 'center',
minHeight: { lg: '400px' }, minHeight: {
lg:
chartData[selectedChart].data.length <= 3
? '350px'
: '450px',
}, // Dynamic height based on legend needs
maxHeight: {
lg:
chartData[selectedChart].data.length <= 3
? '400px'
: '500px',
},
}} }}
> >
<Typography level='h4' textAlign='center' sx={{ mb: 1 }}> <Typography level='h4' textAlign='center' sx={{ mb: 1 }}>
@@ -907,14 +1090,16 @@ const UserActivites = () => {
</Typography> </Typography>
<Box <Box
sx={{ sx={{
flex: 1,
display: 'flex', display: 'flex',
justifyContent: 'center', justifyContent: 'center',
alignItems: 'center', alignItems: 'flex-start',
width: '100%',
}} }}
> >
{renderPieChart( {renderPieChart(
chartData[selectedChart].data, chartData[selectedChart].data,
240, 300, // Increased size for better chart container
true, true,
selectedChart, selectedChart,
)} )}