feat: implement ActivitiesCard component to display recent activities with enhanced status and time display

Support the new Status and Performed By
This commit is contained in:
Mo Tarbin
2025-05-29 01:01:07 -04:00
parent 5c000f50aa
commit 1f2d38730d
9 changed files with 492 additions and 33 deletions

View File

@@ -0,0 +1,411 @@
import {
CheckCircle,
EventNote,
Notes,
Person,
Refresh,
Toll,
WatchLater,
} from '@mui/icons-material'
import {
Avatar,
Box,
Chip,
Divider,
IconButton,
List,
ListItem,
ListItemContent,
ListItemDecorator,
Sheet,
Typography,
} from '@mui/joy'
import moment from 'moment'
import { useChores, useChoresHistory } from '../../queries/ChoreQueries'
import { useCircleMembers } from '../../queries/UserQueries'
import { resolvePhotoURL } from '../../utils/Helpers'
const ActivityItem = ({ activity, members }) => {
// Find the member who completed the activity
const completedByMember = members?.find(
member => member.userId === activity.completedBy,
)
const getTimeDisplay = completedAt => {
const now = moment()
const completed = moment(completedAt)
const diffInHours = now.diff(completed, 'hours')
const diffInDays = now.diff(completed, 'days')
if (diffInHours < 1) {
return 'Just now'
} else if (diffInHours < 24) {
return `${diffInHours}h ago`
} else if (diffInDays < 7) {
return `${diffInDays}d ago`
} else {
return completed.format('MMM DD')
}
}
const getStatusInfo = activity => {
if (!activity.dueDate) {
return {
color: 'neutral',
text: 'Completed',
icon: <CheckCircle />,
}
}
const wasOnTime = moment(activity.completedAt).isSameOrBefore(
moment(activity.dueDate),
)
if (wasOnTime) {
return {
color: 'success',
text: 'On Time',
icon: <CheckCircle />,
}
} else {
return {
color: 'warning',
text: 'Late',
icon: <WatchLater />,
}
}
}
return (
<ListItem sx={{ alignItems: 'flex-start', py: 0.5 }}>
<ListItemDecorator sx={{ mt: 0.5 }}>
<Avatar
size='sm'
src={resolvePhotoURL(completedByMember?.image)}
sx={{ width: 32, height: 32 }}
>
{completedByMember?.displayName?.charAt(0) ||
completedByMember?.name?.charAt(0) || <Person />}
</Avatar>
</ListItemDecorator>
<ListItemContent sx={{ flex: 1 }}>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
{/* Activity header */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography level='title-sm' sx={{ flex: 1 }}>
{activity.choreName}
</Typography>
<Typography level='body-xs' color='text.secondary'>
{getTimeDisplay(activity.completedAt)}
</Typography>
</Box>
{/* Who completed it */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
{/* Status chip */}
<Chip
size='sm'
variant='soft'
color={getStatusInfo(activity).color}
startDecorator={getStatusInfo(activity).icon}
>
{getStatusInfo(activity).text}
</Chip>
<Typography level='body-xs' color='text.secondary' sx={{ ml: 0 }}>
by{' '}
{completedByMember?.displayName ||
completedByMember?.name ||
'Unknown'}
</Typography>
</Box>
{/* Status, Points, and Notes */}
<Box
sx={{
display: 'flex',
flexWrap: 'wrap',
gap: 0.5,
mt: 0.5,
ml: 2.5,
}}
>
{/* Points chip */}
{activity.points && activity.points > 0 && (
<Chip
size='sm'
variant='soft'
color='success'
startDecorator={<Toll />}
>
{activity.points} pts
</Chip>
)}
</Box>
{/* Notes */}
{activity.notes && (
<Box sx={{ mt: 0.5, ml: 2.5 }}>
<Typography
level='body-xs'
sx={{
display: 'flex',
alignItems: 'flex-start',
gap: 0.5,
fontStyle: 'italic',
color: 'text.secondary',
}}
>
<Notes sx={{ fontSize: 14, mt: 0.1 }} />
{activity.notes}
</Typography>
</Box>
)}
</Box>
</ListItemContent>
</ListItem>
)
}
const groupActivitiesByDate = activities => {
const groups = {}
activities.forEach(activity => {
const date = moment(activity.completedAt).format('YYYY-MM-DD')
if (!groups[date]) {
groups[date] = []
}
groups[date].push(activity)
})
return groups
}
const ActivitiesCard = ({ title = 'Recent Activities' }) => {
// Use hooks to fetch data
const {
data: choresData,
isLoading: isChoresLoading,
refetch: refetchChores,
} = useChores(true) // Include archived chores
const {
data: choreHistory,
isLoading: isChoresHistoryLoading,
refetch: refetchHistory,
} = useChoresHistory(10, true) // Limit to 10 items, include members
const {
data: circleMembersData,
isLoading: isCircleMembersLoading,
refetch: refetchMembers,
} = useCircleMembers()
// Extract data from responses
const chores = choresData?.res || []
const members = circleMembersData?.res || []
// Refresh function to refetch all data
const handleRefresh = async () => {
await Promise.all([refetchChores(), refetchHistory(), refetchMembers()])
}
// Show loading state
if (isChoresLoading || isChoresHistoryLoading || isCircleMembersLoading) {
return (
<Sheet
variant='plain'
sx={{
p: 2,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'space-between',
boxShadow: 'sm',
borderRadius: 20,
minHeight: 300,
width: '315px',
mb: 1,
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
<EventNote />
<Typography level='title-md'>{title}</Typography>
</Box>
<Box
sx={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
height: 200,
}}
>
<Typography level='body-sm' color='neutral'>
Loading activities...
</Typography>
</Box>
</Sheet>
)
}
// Enrich history with chore names
const enrichedHistory =
choreHistory?.map(history => {
const chore = chores?.find(c => c.id === history.choreId)
return {
...history,
choreName: chore?.name || 'Unknown Chore',
}
}) || []
// Sort by completion date (most recent first)
const sortedHistory = enrichedHistory
.sort(
(a, b) =>
moment(b.completedAt).valueOf() - moment(a.completedAt).valueOf(),
)
.slice(0, 10) // Show only latest 10 activities
const groupedActivities = groupActivitiesByDate(sortedHistory)
if (!sortedHistory.length) {
return (
<Sheet
variant='plain'
sx={{
p: 2,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'space-between',
boxShadow: 'sm',
borderRadius: 20,
// width: '290px',
minHeight: 300,
mb: 1,
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
<EventNote color='primary' />
<Typography level='title-md'>{title}</Typography>
</Box>
<Box
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
minHeight: 200,
color: 'text.secondary',
}}
>
<EventNote sx={{ fontSize: 48, opacity: 0.3, mb: 1 }} />
<Typography level='body-sm'>No recent activities</Typography>
</Box>
</Sheet>
)
}
return (
<Sheet
variant='plain'
sx={{
p: 2,
display: 'flex',
flexDirection: 'column',
boxShadow: 'sm',
borderRadius: 20,
width: '310px',
minHeight: 300,
maxHeight: 400,
mb: 1,
}}
>
{/* Header */}
<Box sx={{ mb: 2 }}>
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<EventNote color='primary' />
<Typography level='title-md'>{title}</Typography>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Chip size='sm' variant='soft' color='neutral'>
{sortedHistory.length}
</Chip>
<IconButton
size='sm'
variant='soft'
color='neutral'
onClick={handleRefresh}
sx={{ minHeight: 24, minWidth: 24 }}
>
<Refresh sx={{ fontSize: 16 }} />
</IconButton>
</Box>
</Box>
</Box>
{/* Scrollable activity list */}
<Box sx={{ maxHeight: 280, overflowY: 'auto', flex: 1 }}>
{Object.entries(groupedActivities).map(([date, activities]) => {
const isToday = moment(date).isSame(moment(), 'day')
const isYesterday = moment(date).isSame(
moment().subtract(1, 'day'),
'day',
)
let dateLabel
if (isToday) {
dateLabel = 'Today'
} else if (isYesterday) {
dateLabel = 'Yesterday'
} else {
dateLabel = moment(date).format('MMM DD')
}
return (
<Box key={date} sx={{ mb: 1 }}>
{/* Date separator */}
<Box sx={{ display: 'flex', alignItems: 'center', my: 1, px: 1 }}>
<Divider sx={{ flex: 1 }} />
<Typography
level='body-xs'
sx={{
px: 1,
pr: 1,
mt: -1,
color: 'text.secondary',
fontSize: '0.75rem',
fontWeight: 500,
}}
>
{dateLabel}
</Typography>
<Divider sx={{ flex: 1 }} />
</Box>
{/* Activities for this date */}
<List sx={{ py: 0 }}>
{activities.map(activity => (
<ActivityItem
key={activity.id}
activity={activity}
members={members}
/>
))}
</List>
</Box>
)
})}
</Box>
</Sheet>
)
}
export default ActivitiesCard

View File

@@ -12,7 +12,7 @@ import {
Typography,
} from '@mui/joy'
import moment from 'moment'
import React, { useEffect, useState } from 'react'
import { useEffect, useState } from 'react'
import { Link, useParams } from 'react-router-dom'
import {
DeleteChoreHistory,
@@ -69,9 +69,9 @@ const ChoreHistory = () => {
const averageDelay =
histories.reduce((acc, chore) => {
if (chore.dueDate && chore.completedAt) {
if (chore.dueDate && chore.performedAt) {
// Only consider chores with a due date
return acc + moment(chore.completedAt).diff(chore.dueDate, 'hours')
return acc + moment(chore.performedAt).diff(chore.dueDate, 'hours')
}
return acc
}, 0) / histories.filter(chore => chore.dueDate).length
@@ -79,7 +79,7 @@ const ChoreHistory = () => {
const maximumDelay = histories.reduce((acc, chore) => {
if (chore.dueDate) {
// Only consider chores with a due date
const delay = moment(chore.completedAt).diff(chore.dueDate, 'hours')
const delay = moment(chore.performedAt).diff(chore.dueDate, 'hours')
return delay > acc ? delay : acc
}
return acc
@@ -242,7 +242,7 @@ const ChoreHistory = () => {
},
onSave: updated => {
UpdateChoreHistory(choreId, editHistory.id, {
completedAt: updated.completedAt,
performedAt: updated.performedAt,
dueDate: updated.dueDate,
notes: updated.notes,
}).then(res => {

View File

@@ -18,15 +18,15 @@ export const getCompletedChip = historyEntry => {
// if completed few hours +-6 hours
if (
historyEntry.dueDate &&
historyEntry.completedAt > historyEntry.dueDate - 1000 * 60 * 60 * 6 &&
historyEntry.completedAt < historyEntry.dueDate + 1000 * 60 * 60 * 6
historyEntry.performedAt > historyEntry.dueDate - 1000 * 60 * 60 * 6 &&
historyEntry.performedAt < historyEntry.dueDate + 1000 * 60 * 60 * 6
) {
text = 'On Time'
color = 'success'
icon = <Check />
} else if (
historyEntry.dueDate &&
historyEntry.completedAt < historyEntry.dueDate
historyEntry.performedAt < historyEntry.dueDate
) {
text = 'On Time'
color = 'success'
@@ -36,7 +36,7 @@ export const getCompletedChip = historyEntry => {
// if completed after due date then it's late
else if (
historyEntry.dueDate &&
historyEntry.completedAt > historyEntry.dueDate
historyEntry.performedAt > historyEntry.dueDate
) {
text = 'Late'
color = 'warning'
@@ -104,8 +104,8 @@ const HistoryCard = ({
}}
>
<Typography level='body1' sx={{ fontWeight: 'md' }}>
{historyEntry.completedAt
? moment(historyEntry.completedAt).format(
{historyEntry.performedAt
? moment(historyEntry.performedAt).format(
'ddd MM/DD/yyyy HH:mm',
)
: 'Skipped'}
@@ -150,11 +150,11 @@ const HistoryCard = ({
<ListDivider component='li'>
{/* time between two completion: */}
{index < allHistory.length - 1 &&
allHistory[index + 1].completedAt && (
allHistory[index + 1].performedAt && (
<Typography level='body3' color='text.tertiary'>
{formatTimeDifference(
historyEntry.completedAt,
allHistory[index + 1].completedAt,
historyEntry.performedAt,
allHistory[index + 1].performedAt,
)}{' '}
before
</Typography>

View File

@@ -14,7 +14,7 @@ import ConfirmationModal from './Inputs/ConfirmationModal'
function EditHistoryModal({ config, historyRecord }) {
useEffect(() => {
setCompletedDate(
moment(historyRecord.completedAt).format('YYYY-MM-DDTHH:mm'),
moment(historyRecord.performedAt).format('YYYY-MM-DDTHH:mm'),
)
setDueDate(moment(historyRecord.dueDate).format('YYYY-MM-DDTHH:mm'))
setNotes(historyRecord.notes)
@@ -76,7 +76,7 @@ function EditHistoryModal({ config, historyRecord }) {
onClick={() =>
config.onSave({
id: historyRecord.id,
completedAt: moment(completedDate).toISOString(),
performedAt: moment(completedDate).toISOString(),
dueDate: moment(dueDate).toISOString(),
notes,
})

View File

@@ -35,7 +35,7 @@ const groupByDate = history => {
const aggregated = {}
for (let i = 0; i < history.length; i++) {
const item = history[i]
const date = new Date(item.completedAt).toLocaleDateString()
const date = new Date(item.performedAt).toLocaleDateString()
if (!aggregated[date]) {
aggregated[date] = []
}
@@ -114,7 +114,7 @@ const ChoreHistoryTimeline = ({ history }) => {
<>
<ChoreHistoryItem
key={record.id}
time={new Date(record.completedAt).toLocaleTimeString([], {
time={new Date(record.performedAt).toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
})}
@@ -289,9 +289,9 @@ const UserActivites = () => {
const generateHistoryPieChartData = history => {
const totalCompleted =
history.filter(item => item.dueDate > item.completedAt).length || 0
history.filter(item => item.dueDate > item.performedAt).length || 0
const totalLate =
history.filter(item => item.dueDate < item.completedAt).length || 0
history.filter(item => item.dueDate < item.performedAt).length || 0
const totalNoDueDate = history.filter(item => !item.dueDate).length || 0
return [

View File

@@ -107,7 +107,7 @@ const UserPoints = () => {
})
}
history.forEach(chore => {
const dayName = new Date(chore.completedAt).toLocaleString('en-US', {
const dayName = new Date(chore.performedAt).toLocaleString('en-US', {
weekday: 'short',
})
@@ -136,7 +136,7 @@ const UserPoints = () => {
})
}
history.forEach(chore => {
const dayName = new Date(chore.completedAt).toLocaleString('en-US', {
const dayName = new Date(chore.performedAt).toLocaleString('en-US', {
day: 'numeric',
})
@@ -167,7 +167,7 @@ const UserPoints = () => {
})
}
history.forEach(chore => {
const monthName = new Date(chore.completedAt).toLocaleString('en-US', {
const monthName = new Date(chore.performedAt).toLocaleString('en-US', {
month: 'short',
})
@@ -198,7 +198,7 @@ const UserPoints = () => {
})
}
history.forEach(chore => {
const yearName = new Date(chore.completedAt).toLocaleString('en-US', {
const yearName = new Date(chore.performedAt).toLocaleString('en-US', {
year: 'numeric',
})

View File

@@ -1,6 +1,6 @@
import { useColorScheme } from '@mui/joy'
import { useEffect, useRef, useState } from 'react'
import AutocompleteDropdown from './AutocompleteDropdown'
import AutocompleteDropdown from '../TestView/AutocompleteDropdown'
import './SmartTaskTitleInput.css'
const renderHighlightedText = (text, cursorPosition) => {
const parts = []

View File

@@ -1,4 +1,10 @@
import { DndContext, closestCenter } from '@dnd-kit/core'
import {
DndContext,
PointerSensor,
closestCenter,
useSensor,
useSensors,
} from '@dnd-kit/core'
import {
SortableContext,
arrayMove,
@@ -24,7 +30,7 @@ import {
ListItem,
Typography,
} from '@mui/joy'
import React, { useState } from 'react'
import { useState } from 'react'
import { CompleteSubTask } from '../../utils/Fetcher'
function SortableItem({
@@ -39,7 +45,17 @@ function SortableItem({
editMode,
}) {
const { attributes, listeners, setNodeRef, transform, transition } =
useSortable({ id: task.id })
useSortable({
id: task.id,
// Add touch sensor options for better mobile scrolling
options: {
activationConstraint: {
// Require a small movement before activating drag to allow scrolling
delay: 250,
tolerance: 5,
},
},
})
const [isEditing, setIsEditing] = useState(false)
const [editedText, setEditedText] = useState(task.name)
@@ -58,7 +74,8 @@ function SortableItem({
alignItems: 'center',
gap: '0.5rem',
flexDirection: { xs: 'column', sm: 'row' },
touchAction: 'none',
// Enable default touch behavior for scrolling
touchAction: 'auto',
paddingLeft: `${level * 24}px`,
}
@@ -102,7 +119,15 @@ function SortableItem({
<>
<ListItem ref={setNodeRef} style={style} {...attributes}>
{editMode && (
<IconButton {...listeners} {...attributes} size='sm'>
<IconButton
{...listeners}
{...attributes}
size='sm'
// Add data attribute for selective activation
data-drag-handle='true'
// Only restrict touch actions on the drag handle
sx={{ touchAction: 'none' }}
>
<DragIndicator />
</IconButton>
)}
@@ -119,7 +144,7 @@ function SortableItem({
)}
{!hasChildren && level > 0 && (
<Box sx={{ width: 28 }} /> // Spacer for alignment not sure of better way for now it's good
<Box sx={{ width: 28 }} /> // Spacer for alignment
)}
<Box
@@ -269,6 +294,17 @@ const SubTasks = ({ editMode = true, choreId = 0, tasks = [], setTasks }) => {
const topLevelTasks = tasks.filter(task => task.parentId === null)
// Create sensors for touch handling
const sensors = useSensors(
useSensor(PointerSensor, {
// Configure for better mobile scrolling
activationConstraint: {
delay: 100,
tolerance: 8,
},
}),
)
const handleToggle = taskId => {
const updatedTask = tasks.find(task => task.id === taskId)
const newCompletedAt = updatedTask.completedAt
@@ -405,9 +441,21 @@ const SubTasks = ({ editMode = true, choreId = 0, tasks = [], setTasks }) => {
return (
<>
<DndContext collisionDetection={closestCenter} onDragEnd={onDragEnd}>
<DndContext
collisionDetection={closestCenter}
onDragEnd={onDragEnd}
sensors={sensors}
>
<SortableContext items={tasks} strategy={verticalListSortingStrategy}>
<List sx={{ padding: 0 }}>
<List
sx={{
padding: 0,
// Improve scrolling behavior on mobile
maxHeight: 'inherit',
overflow: 'visible',
WebkitOverflowScrolling: 'touch',
}}
>
{topLevelTasks
.sort((a, b) => a.orderId - b.orderId)
.map((task, index) => (