Files
donetick/src/views/Chores/ActivitesCard.jsx
everysingletear d813971809 fix: ActivitiesCard crashes — t() in a prop default has no scope
`ActivitiesCard` takes its title as `({ title = t('activity.title') })`.
A default parameter is evaluated in the function's own scope, where `t`
does not exist: the only `t` in the file is bound inside `ActivityItem`,
a separate component. `Sidepanel` renders the card without a `title`, and
`activities` is `enabled: true` in `DEFAULT_SIDEPANEL_CONFIG`, so the
default is always evaluated on a desktop-width screen.

The hook moves into the component body and the title falls back there
instead. Same rendered output; three render sites now read `displayTitle`.

This is my regression: it arrived with #216, which described itself as
"no behaviour change". It was not caught because a bundler cannot flag it
— an unbound `t` is indistinguishable from a global — and my checks only
verified that keys and imports existed, not that `t` was in scope. It is
visible in the built bundle: `({title:e=t("activity.title")})` keeps the
literal `t` because the minifier cannot rename a free variable, while a
working call nearby minifies to `q=e("common.confirm")`.

I have added an eslint `no-undef` pass to my own pipeline and run it
before sending anything from now on.
2026-08-15 17:01:18 +08:00

517 lines
14 KiB
JavaScript

import {
CheckCircle,
EventNote,
HourglassEmpty,
Notes,
Person,
Redo,
Refresh,
ThumbDown,
Timelapse,
Toll,
WatchLater,
} from '@mui/icons-material'
import {
Avatar,
Box,
Chip,
Divider,
IconButton,
Link,
List,
ListItem,
ListItemContent,
ListItemDecorator,
Sheet,
Typography,
} from '@mui/joy'
import moment from 'moment'
import { useState } from 'react'
import { useChores, useChoresHistory } from '../../queries/ChoreQueries'
import { useCircleMembers } from '../../queries/UserQueries'
import { resolvePhotoURL } from '../../utils/Helpers'
import NoteViewerModal from '../Modals/Inputs/NoteViewerModal'
import { useTranslation } from 'react-i18next'
const ActivityItem = ({ activity, members, onViewNote }) => {
const { t } = useTranslation('chores')
// Find the member who completed the activity
const completedByMember = members?.find(
member => member.userId === activity.completedBy,
)
// Strip HTML tags from notes for plain text display
const stripHtmlTags = html => {
if (!html) return ''
const div = document.createElement('div')
div.innerHTML = html
return div.textContent || div.innerText || ''
}
const plainTextNotes = activity.notes ? stripHtmlTags(activity.notes) : ''
// Calculate if notes should be truncated (more than 2 lines in the UI, which is roughly 100 characters)
const shouldTruncate = plainTextNotes && plainTextNotes.length > 80
const getTimeDisplay = dateToDisplay => {
const now = moment()
const completed = moment(dateToDisplay)
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.status === 0) {
return {
color: 'primary',
text: 'Started',
icon: <Timelapse />,
}
} else if (activity.status === 1) {
const wasOnTime =
!activity.dueDate ||
moment(activity.performedAt).isSameOrBefore(moment(activity.dueDate))
if (wasOnTime) {
return {
color: 'success',
text: 'Done',
icon: <CheckCircle />,
}
} else {
return {
color: 'primary',
text: 'Late',
icon: <WatchLater />,
}
}
} else if (activity.status === 2) {
return {
color: 'warning',
text: 'Skipped',
icon: <Redo />,
}
} else if (activity.status === 3) {
return {
color: 'neutral',
text: 'Pending Approval',
icon: <HourglassEmpty />,
}
} else if (activity.status === 4) {
return {
color: 'danger',
text: 'Rejected',
icon: <ThumbDown />,
}
} else if (activity.status === 5) {
return {
color: 'danger',
text: 'Missed',
icon: <EventNote />,
}
} else if (activity.status === 6) {
return {
color: 'neutral',
text: 'Rescheduled',
icon: <Refresh />,
}
}
// Fallback for completed status
return {
color: 'success',
text: 'Completed',
icon: <CheckCircle />,
}
}
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.performedAt ||
activity.updatedAt ||
activity.createdAt,
)}
</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>
{/* Points chip */}
{activity.points && activity.points > 0 && (
<Chip
size='sm'
variant='soft'
color='success'
startDecorator={<Toll />}
>
{activity.points} pts
</Chip>
)}
</Box>
{/* Status, Points, and Notes */}
<Box
sx={{
display: 'flex',
flexWrap: 'wrap',
gap: 0.5,
mt: 0.5,
ml: 2.5,
}}
></Box>
{/* Notes */}
{plainTextNotes && (
<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, flexShrink: 0 }} />
<Box sx={{ flex: 1 }}>
<Typography
level='body-xs'
sx={{
fontStyle: 'italic',
color: 'text.secondary',
overflow: 'hidden',
display: '-webkit-box',
WebkitLineClamp: shouldTruncate ? 2 : 'unset',
WebkitBoxOrient: 'vertical',
wordBreak: 'break-word',
}}
>
{plainTextNotes}
</Typography>
{shouldTruncate && (
<Link
level='body-xs'
onClick={() => onViewNote(activity.notes)}
sx={{
cursor: 'pointer',
mt: 0.25,
display: 'inline-block',
}}
>
{t('activity.showMore')}
</Link>
)}
</Box>
</Typography>
</Box>
)}
</Box>
</ListItemContent>
</ListItem>
)
}
const groupActivitiesByDate = activities => {
const groups = {}
activities.forEach(activity => {
const date = moment(
activity.performedAt || activity.updatedAt || activity.createdAt,
).format('YYYY-MM-DD')
if (!groups[date]) {
groups[date] = []
}
groups[date].push(activity)
})
return groups
}
const ActivitiesCard = ({ title }) => {
const { t } = useTranslation('chores')
const displayTitle = title || t('activity.title')
const [noteViewerConfig, setNoteViewerConfig] = useState({ isOpen: false })
// 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',
boxShadow: 'sm',
borderRadius: 20,
width: '310px',
minHeight: 300,
maxHeight: 400,
mb: 1,
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
<Typography level='title-md'>{displayTitle}</Typography>
</Box>
<Box
sx={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
height: 200,
}}
>
<Typography level='body-sm' color='neutral'>
{t('activity.loading')}
</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.performedAt || b.updatedAt).valueOf() -
moment(a.performedAt || a.updatedAt).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',
boxShadow: 'sm',
borderRadius: 20,
width: '310px',
minHeight: 300,
maxHeight: 400,
mb: 1,
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
<EventNote color='' />
<Typography level='title-md'>{displayTitle}</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='' />
<Typography level='title-md'>{displayTitle}</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}
onViewNote={notes => {
setNoteViewerConfig({
isOpen: true,
title: `Note - ${activity.choreName}`,
content: notes,
onClose: () => setNoteViewerConfig({ isOpen: false }),
})
}}
/>
))}
</List>
</Box>
)
})}
</Box>
<NoteViewerModal config={noteViewerConfig} />
</Sheet>
)
}
export default ActivitiesCard