Enhance user experience with Plus feature notifications and improve code structure

- Updated ChoreEdit and RepeatSection components to provide clearer messaging for Basic plan users regarding Plus features.
- Introduced CompactChoreCard component to streamline chore display options in MyChores.
- Implemented view mode toggle in MyChores for better user preference handling.
- Refactored StorageSettings to conditionally display storage usage information based on user plan.
- Improved error handling and user feedback across various components.
This commit is contained in:
Mo Tarbin
2025-05-30 01:46:26 -04:00
parent 1510dc9bc2
commit 05f4c42bf9
9 changed files with 1040 additions and 143 deletions

View File

@@ -692,10 +692,16 @@ const ChoreEdit = () => {
Get Reminders when this task is due or completed
{!isPlusAccount(userProfile) && (
<Chip variant='soft' color='warning'>
Not available in Basic Plan
Plus Feature
</Chip>
)}
</Typography>
{!isPlusAccount(userProfile) && (
<Typography level='body-sm' color='warning' sx={{ mb: 1 }}>
Task notifications are not available in the Basic plan. Upgrade to
Plus to receive reminders when tasks are due or completed.
</Typography>
)}
<FormControl sx={{ mt: 1 }}>
<Checkbox

View File

@@ -65,10 +65,8 @@ const RepeatOnSections = ({
frequencyType,
frequency,
onFrequencyUpdate,
onFrequencyTypeUpdate,
frequencyMetadata,
onFrequencyMetadataUpdate,
things,
}) => {
const [intervalUnit, setIntervalUnit] = useState('days')
// if time on frequencyMetadata is not set, try to set it to the nextDueDate if available,
@@ -79,7 +77,7 @@ const RepeatOnSections = ({
moment(new Date()).format('YYYY-MM-DD') + 'T' + '18:00',
).format()
}
}, [])
}, [frequencyMetadata])
const timePickerComponent = (
<Grid item sm={12} sx={{ display: 'flex', alignItems: 'center' }}>
@@ -339,8 +337,7 @@ const RepeatSection = ({
isAttemptToSave,
selectedThing,
}) => {
const [repeatOn, setRepeatOn] = useState('interval')
const { userProfile, setUserProfile } = useContext(UserContext)
const { userProfile } = useContext(UserContext)
return (
<Box mt={2}>
<Typography level='h4'>Repeat :</Typography>
@@ -375,7 +372,7 @@ const RepeatSection = ({
'--ListItem-radius': '20px',
}}
>
{FREQUENCY_TYPES_RADIOS.map((item, index) => (
{FREQUENCY_TYPES_RADIOS.map(item => (
<ListItem key={item}>
<Checkbox
// disabled={index === 0}
@@ -557,10 +554,16 @@ const RepeatSection = ({
Is this something that should be done when a thing state changes?{' '}
{userProfile && !isPlusAccount(userProfile) && (
<Chip variant='soft' color='warning'>
Not available in Basic Plan
Plus Feature
</Chip>
)}
</FormHelperText>
{!isPlusAccount(userProfile) && (
<Typography level='body-sm' color='warning' sx={{ mt: 1 }}>
Thing-based triggers are not available in the Basic plan. Upgrade to
Plus to automatically trigger tasks when device states change.
</Typography>
)}
</FormControl>
{frequencyType === 'trigger' && (
<ThingTriggerSection

View File

@@ -3,6 +3,7 @@ import {
EventNote,
Notes,
Person,
Redo,
Refresh,
Toll,
WatchLater,
@@ -31,9 +32,9 @@ const ActivityItem = ({ activity, members }) => {
member => member.userId === activity.completedBy,
)
const getTimeDisplay = completedAt => {
const getTimeDisplay = performedAt => {
const now = moment()
const completed = moment(completedAt)
const completed = moment(performedAt)
const diffInHours = now.diff(completed, 'hours')
const diffInDays = now.diff(completed, 'days')
@@ -49,27 +50,34 @@ const ActivityItem = ({ activity, members }) => {
}
const getStatusInfo = activity => {
if (!activity.dueDate) {
if (!activity.status === 1) {
return {
color: 'neutral',
text: 'Completed',
icon: <CheckCircle />,
}
} else if (activity.status === 2) {
// skipped
return {
color: 'warning',
text: 'Skipped',
icon: <Redo />,
}
}
const wasOnTime = moment(activity.completedAt).isSameOrBefore(
const wasOnTime = moment(activity.performedAt).isSameOrBefore(
moment(activity.dueDate),
)
if (wasOnTime) {
return {
color: 'success',
text: 'On Time',
text: 'Done',
icon: <CheckCircle />,
}
} else {
return {
color: 'warning',
color: 'primary',
text: 'Late',
icon: <WatchLater />,
}
@@ -97,13 +105,14 @@ const ActivityItem = ({ activity, members }) => {
{activity.choreName}
</Typography>
<Typography level='body-xs' color='text.secondary'>
{getTimeDisplay(activity.completedAt)}
{getTimeDisplay(activity.performedAt)}
</Typography>
</Box>
{/* Who completed it */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
{/* Status chip */}
<Chip
size='sm'
variant='soft'
@@ -171,7 +180,7 @@ const groupActivitiesByDate = activities => {
const groups = {}
activities.forEach(activity => {
const date = moment(activity.completedAt).format('YYYY-MM-DD')
const date = moment(activity.performedAt).format('YYYY-MM-DD')
if (!groups[date]) {
groups[date] = []
}
@@ -229,7 +238,6 @@ const ActivitiesCard = ({ title = 'Recent Activities' }) => {
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
<EventNote />
<Typography level='title-md'>{title}</Typography>
</Box>
<Box
@@ -262,7 +270,7 @@ const ActivitiesCard = ({ title = 'Recent Activities' }) => {
const sortedHistory = enrichedHistory
.sort(
(a, b) =>
moment(b.completedAt).valueOf() - moment(a.completedAt).valueOf(),
moment(b.performedAt).valueOf() - moment(a.performedAt).valueOf(),
)
.slice(0, 10) // Show only latest 10 activities
@@ -286,7 +294,7 @@ const ActivitiesCard = ({ title = 'Recent Activities' }) => {
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
<EventNote color='primary' />
<EventNote color='' />
<Typography level='title-md'>{title}</Typography>
</Box>
<Box
@@ -331,7 +339,7 @@ const ActivitiesCard = ({ title = 'Recent Activities' }) => {
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<EventNote color='primary' />
<EventNote color='' />
<Typography level='title-md'>{title}</Typography>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>

View File

@@ -1,35 +0,0 @@
import { Box, Checkbox, Typography } from '@mui/joy'
export const CompactCard = ({
chore,
performers,
onChoreUpdate,
onChoreRemove,
userLabels,
sx,
viewOnly,
onChipClick,
}) => {
return (
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 1,
flex: 1,
}}
>
<Checkbox
// checked={!!task.completedAt}
// onChange={() => handleToggle(task.id)}
/>
<Typography
sx={{
textDecoration: chore.completedAt ? 'line-through' : 'none',
}}
>
{chore.name}
</Typography>
</Box>
)
}

View File

@@ -0,0 +1,845 @@
import {
Archive,
CancelScheduleSend,
Check,
CopyAll,
Delete,
Edit,
ManageSearch,
MoreTime,
MoreVert,
Nfc,
NoteAdd,
RecordVoiceOver,
Repeat,
SwitchAccessShortcut,
TimesOneMobiledata,
Unarchive,
Update,
ViewCarousel,
Webhook,
} from '@mui/icons-material'
import {
Box,
Button,
Chip,
CircularProgress,
Divider,
IconButton,
Menu,
MenuItem,
Snackbar,
Typography,
} from '@mui/joy'
import moment from 'moment'
import React, { useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import { useImpersonateUser } from '../../contexts/ImpersonateUserContext.jsx'
import { UserContext } from '../../contexts/UserContext'
import { useError } from '../../service/ErrorProvider'
import { notInCompletionWindow } from '../../utils/Chores.jsx'
import { getTextColorFromBackgroundColor } from '../../utils/Colors.jsx'
import {
ArchiveChore,
DeleteChore,
MarkChoreComplete,
SkipChore,
UnArchiveChore,
UpdateChoreAssignee,
UpdateDueDate,
} from '../../utils/Fetcher'
import Priorities from '../../utils/Priorities'
import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
import DateModal from '../Modals/Inputs/DateModal'
import SelectModal from '../Modals/Inputs/SelectModal'
import TextModal from '../Modals/Inputs/TextModal'
import WriteNFCModal from '../Modals/Inputs/WriteNFCModal'
const CompactChoreCard = ({
chore,
performers,
onChoreUpdate,
onChoreRemove,
sx,
viewOnly,
onChipClick,
}) => {
const [activeUserId, setActiveUserId] = React.useState(0)
const [isChangeDueDateModalOpen, setIsChangeDueDateModalOpen] =
React.useState(false)
const [isCompleteWithPastDateModalOpen, setIsCompleteWithPastDateModalOpen] =
React.useState(false)
const [isChangeAssigneeModalOpen, setIsChangeAssigneeModalOpen] =
React.useState(false)
const [isCompleteWithNoteModalOpen, setIsCompleteWithNoteModalOpen] =
React.useState(false)
const [confirmModelConfig, setConfirmModelConfig] = React.useState({})
const [isNFCModalOpen, setIsNFCModalOpen] = React.useState(false)
const [anchorEl, setAnchorEl] = React.useState(null)
const menuRef = React.useRef(null)
const navigate = useNavigate()
const [isDisabled, setIsDisabled] = React.useState(false)
const [isPendingCompletion, setIsPendingCompletion] = React.useState(false)
const [secondsLeftToCancel, setSecondsLeftToCancel] = React.useState(null)
const [timeoutId, setTimeoutId] = React.useState(null)
const { userProfile } = React.useContext(UserContext)
const { impersonatedUser } = useImpersonateUser()
const { showError } = useError()
useEffect(() => {
document.addEventListener('mousedown', handleMenuOutsideClick)
return () => {
document.removeEventListener('mousedown', handleMenuOutsideClick)
}
}, [anchorEl])
const handleMenuOpen = event => {
setAnchorEl(event.currentTarget)
}
const handleMenuClose = () => {
setAnchorEl(null)
}
const handleMenuOutsideClick = event => {
if (
anchorEl &&
!anchorEl.contains(event.target) &&
!menuRef.current.contains(event.target)
) {
handleMenuClose()
}
}
// All the existing handler methods (same as original ChoreCard)
const handleEdit = () => {
navigate(`/chores/${chore.id}/edit`)
}
const handleClone = () => {
navigate(`/chores/${chore.id}/edit?clone=true`)
}
const handleView = () => {
navigate(`/chores/${chore.id}`)
}
const handleDelete = () => {
setConfirmModelConfig({
isOpen: true,
title: 'Delete Chore',
confirmText: 'Delete',
cancelText: 'Cancel',
message: 'Are you sure you want to delete this chore?',
onClose: isConfirmed => {
if (isConfirmed === true) {
DeleteChore(chore.id).then(response => {
if (response.ok) {
onChoreRemove(chore)
}
})
}
setConfirmModelConfig({})
},
})
}
const handleArchive = () => {
if (chore.isActive) {
ArchiveChore(chore.id).then(response => {
if (response.ok) {
response.json().then(data => {
const newChore = { ...chore, isActive: false }
onChoreUpdate(newChore, 'archive')
})
}
})
} else {
UnArchiveChore(chore.id).then(response => {
if (response.ok) {
response.json().then(data => {
const newChore = { ...chore, isActive: true }
onChoreUpdate(newChore, 'unarchive')
})
}
})
}
handleMenuClose()
}
const handleTaskCompletion = () => {
setIsPendingCompletion(true)
let seconds = 3
setSecondsLeftToCancel(seconds)
const countdownInterval = setInterval(() => {
seconds -= 1
setSecondsLeftToCancel(seconds)
if (seconds <= 0) {
clearInterval(countdownInterval)
setIsPendingCompletion(false)
}
}, 1000)
const id = setTimeout(() => {
MarkChoreComplete(
chore.id,
impersonatedUser ? { completedBy: impersonatedUser.userId } : null,
null,
null,
)
.then(resp => {
if (resp.ok) {
return resp.json().then(data => {
onChoreUpdate(data.res, 'completed')
})
}
})
.then(() => {
setIsPendingCompletion(false)
clearTimeout(id)
clearInterval(countdownInterval)
setTimeoutId(null)
setSecondsLeftToCancel(null)
})
.catch(error => {
if (error?.queued) {
showError({
title: 'Update Failed',
message: 'Request will be reattempt when you are online',
})
} else {
showError({
title: 'Failed to update',
message: error,
})
}
setIsPendingCompletion(false)
clearTimeout(id)
clearInterval(countdownInterval)
setTimeoutId(null)
setSecondsLeftToCancel(null)
})
}, 2000)
setTimeoutId(id)
}
const handleChangeDueDate = newDate => {
if (activeUserId === null) {
alert('Please select a performer')
return
}
UpdateDueDate(chore.id, newDate).then(response => {
if (response.ok) {
response.json().then(data => {
const newChore = data.res
onChoreUpdate(newChore, 'rescheduled')
})
}
})
}
const handleCompleteWithPastDate = newDate => {
if (activeUserId === null) {
alert('Please select a performer')
return
}
MarkChoreComplete(
chore.id,
impersonatedUser ? { completedBy: impersonatedUser.userId } : null,
new Date(newDate).toISOString(),
null,
).then(response => {
if (response.ok) {
response.json().then(data => {
const newChore = data.res
onChoreUpdate(newChore, 'completed')
})
}
})
}
const handleAssigneChange = assigneeId => {
UpdateChoreAssignee(chore.id, assigneeId).then(response => {
if (response.ok) {
response.json().then(data => {
const newChore = data.res
onChoreUpdate(newChore, 'assigned')
})
}
})
}
const handleCompleteWithNote = note => {
MarkChoreComplete(
chore.id,
impersonatedUser
? { note, completedBy: impersonatedUser.userId }
: { note },
null,
null,
).then(response => {
if (response.ok) {
response.json().then(data => {
const newChore = data.res
onChoreUpdate(newChore, 'completed')
})
}
})
}
// Utility functions
const getDueDateText = nextDueDate => {
if (chore.nextDueDate === null) return 'No Due Date'
const diff = moment(nextDueDate).diff(moment(), 'hours')
if (diff < 24 && diff > 0) {
return moment(nextDueDate).calendar().replace(' at', '')
}
if (diff < 0) {
return 'Overdue'
}
return moment(nextDueDate).fromNow()
}
const getDueDateColor = nextDueDate => {
if (chore.nextDueDate === null) return 'neutral'
const diff = moment(nextDueDate).diff(moment(), 'hours')
if (diff < 48 && diff > 0) {
return 'warning'
}
if (diff < 0) {
return 'danger'
}
return 'neutral'
}
const getRecurrentText = chore => {
// if chore.frequencyMetadata is type string then parse it otherwise assigned to the metadata:
const metadata =
typeof chore.frequencyMetadata === 'string'
? JSON.parse(chore.frequencyMetadata)
: chore.frequencyMetadata
const dayOfMonthSuffix = n => {
if (n >= 11 && n <= 13) {
return 'th'
}
switch (n % 10) {
case 1:
return 'st'
case 2:
return 'nd'
case 3:
return 'rd'
default:
return 'th'
}
}
if (chore.frequencyType === 'once') {
return 'Once'
} else if (chore.frequencyType === 'trigger') {
return 'Trigger'
} else if (chore.frequencyType === 'daily') {
return 'Daily'
} else if (chore.frequencyType === 'adaptive') {
return 'Adaptive'
} else if (chore.frequencyType === 'weekly') {
return 'Weekly'
} else if (chore.frequencyType === 'monthly') {
return 'Monthly'
} else if (chore.frequencyType === 'yearly') {
return 'Yearly'
} else if (chore.frequencyType === 'days_of_the_week') {
let days = metadata.days
if (days.length > 4) {
const allDays = [
'Sunday',
'Monday',
'Tuesday',
'Wednesday',
'Thursday',
'Friday',
'Saturday',
]
const selectedDays = days.map(d => moment().day(d).format('dddd'))
const notSelectedDay = allDays.filter(
day => !selectedDays.includes(day),
)
const notSelectedShortdays = notSelectedDay.map(d =>
moment().day(d).format('ddd'),
)
return `Daily except ${notSelectedShortdays.join(', ')}`
} else {
days = days.map(d => moment().day(d).format('ddd'))
return days.join(', ')
}
} else if (chore.frequencyType === 'day_of_the_month') {
let months = metadata.months
if (months.length > 6) {
const allMonths = [
'January',
'February',
'March',
'April',
'May',
'June',
'July',
'August',
'September',
'October',
'November',
'December',
]
const selectedMonths = months.map(m => moment().month(m).format('MMMM'))
const notSelectedMonth = allMonths.filter(
month => !selectedMonths.includes(month),
)
const notSelectedShortMonths = notSelectedMonth.map(m =>
moment().month(m).format('MMM'),
)
let result = `Monthly ${chore.frequency}${dayOfMonthSuffix(
chore.frequency,
)}`
if (notSelectedShortMonths.length > 0)
result += `
except ${notSelectedShortMonths.join(', ')}`
return result
} else {
let freqData = metadata
const months = freqData.months.map(m => moment().month(m).format('MMM'))
return `${chore.frequency}${dayOfMonthSuffix(
chore.frequency,
)} of ${months.join(', ')}`
}
} else if (chore.frequencyType === 'interval') {
return `Every ${chore.frequency} ${metadata.unit}`
} else {
return chore.frequencyType
}
}
const getFrequencyIcon = chore => {
if (['once', 'no_repeat'].includes(chore.frequencyType)) {
return <TimesOneMobiledata sx={{ fontSize: 14 }} />
} else if (chore.frequencyType === 'trigger') {
return <Webhook sx={{ fontSize: 14 }} />
} else {
return <Repeat sx={{ fontSize: 14 }} />
}
}
const formatMetadata = () => {
const parts = []
// Frequency
parts.push(getRecurrentText(chore))
// Assignee (if not current user)
if (userProfile && chore.assignedTo !== userProfile.id) {
const assignee = performers.find(
p => p.id === chore.assignedTo,
)?.displayName
if (assignee) parts.push(assignee)
}
// Points
if (chore.points > 0) {
parts.push(`${chore.points}pts`)
}
return parts.join(' • ')
}
return (
<Box key={chore.id + '-compact-box'}>
<Box
style={viewOnly ? { pointerEvents: 'none' } : {}}
sx={{
...sx,
display: 'flex',
alignItems: 'center',
px: 1,
// py: 0.75,
minHeight: 56, // More compact height
cursor: 'pointer',
borderBottom: '1px solid',
borderColor: 'divider',
'&:hover': {
bgcolor: 'background.level1',
},
'&:last-child': {
borderBottom: 'none',
},
}}
onClick={() => navigate(`/chores/${chore.id}`)}
>
{/* Left side - Content */}
<Box
sx={{
flex: 1,
minWidth: 0,
mr: 1.5,
display: 'flex',
flexDirection: 'column',
}}
>
{/* Line 1: Name + Due Date + Frequency */}
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
mb: 0.25,
}}
>
<Box
sx={{
display: 'flex',
alignItems: 'center',
minWidth: 0,
flex: 1,
}}
>
{/* Chore Name */}
<Typography
level='title-sm'
sx={{
fontWeight: 600,
fontSize: 14,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
mr: 1,
}}
>
{chore.name}
</Typography>
</Box>
{/* Due Date */}
<Chip
variant='soft'
size='sm'
color={getDueDateColor(chore.nextDueDate)}
sx={{ fontSize: 10, height: 20, flexShrink: 0 }}
>
{getDueDateText(chore.nextDueDate)}
</Chip>
</Box>
{/* Line 2: Metadata */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.25 }}>
{getFrequencyIcon(chore)}
<Typography
level='body-xs'
color='text.secondary'
sx={{
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
fontSize: 11,
}}
>
{formatMetadata()}
</Typography>
{/* Labels */}
{chore.priority > 0 && (
<Chip
variant='solid'
size='sm'
color={
chore.priority === 1
? 'danger'
: chore.priority === 2
? 'warning'
: 'neutral'
}
startDecorator={
Priorities.find(p => p.value === chore.priority)?.icon
}
onClick={e => {
e.stopPropagation()
onChipClick({ priority: chore.priority })
}}
sx={{
ml: 0.5,
// height: 16,
// fontSize: 9,
// px: 0.5,
}}
>
P{chore.priority}
</Chip>
)}
{chore.labelsV2?.map(l => (
<div
role='none'
tabIndex={0}
onClick={e => {
e.stopPropagation()
onChipClick({ label: l })
}}
onKeyDown={e => {
if (e.key === 'Enter' || e.key === ' ') {
e.stopPropagation()
onChipClick({ label: l })
}
}}
style={{
display: 'inline-block',
cursor: 'pointer',
// remove any padding or margin:
padding: 0,
margin: 0,
}}
key={`compact-chorecard-${chore.id}-label-${l.id}`}
>
<Chip
variant='solid'
color='primary'
size='sm'
sx={{
ml: 0.5,
// height: 16,
// fontSize: 9,
// px: 0.5,
backgroundColor: `${l?.color} !important`,
color: getTextColorFromBackgroundColor(l?.color),
}}
>
{l?.name}
</Chip>
</div>
))}
</Box>
</Box>
{/* Right side - Actions */}
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 0.25,
flexShrink: 0,
}}
>
{/* Complete Button */}
<IconButton
variant='solid'
color='success'
size='sm'
onClick={e => {
e.stopPropagation()
handleTaskCompletion()
}}
disabled={isPendingCompletion || notInCompletionWindow(chore)}
sx={{
width: 32,
height: 32,
borderRadius: '50%',
}}
>
{isPendingCompletion ? (
<CircularProgress size='sm' color='success' />
) : (
<Check sx={{ fontSize: 16 }} />
)}
</IconButton>
{/* Menu Button */}
<IconButton
variant='plain'
color='neutral'
size='sm'
onClick={e => {
e.stopPropagation()
handleMenuOpen(e)
}}
sx={{
width: 28,
height: 28,
opacity: 0.6,
'&:hover': {
opacity: 1,
},
}}
>
<MoreVert sx={{ fontSize: 14 }} />
</IconButton>
</Box>
{/* Menu */}
<Menu
ref={menuRef}
anchorEl={anchorEl}
open={Boolean(anchorEl)}
onClose={handleMenuClose}
>
<MenuItem onClick={() => setIsCompleteWithNoteModalOpen(true)}>
<NoteAdd />
Complete with note
</MenuItem>
<MenuItem onClick={() => setIsCompleteWithPastDateModalOpen(true)}>
<Update />
Complete in past
</MenuItem>
<MenuItem
onClick={() => {
SkipChore(chore.id)
.then(response => {
if (response.ok) {
response.json().then(data => {
const newChore = data.res
onChoreUpdate(newChore, 'skipped')
handleMenuClose()
})
}
})
.catch(error => {
if (error?.queued) {
showError({
title: 'Failed to update',
message: 'Request will be processed when you are online',
})
} else {
showError({
title: 'Failed to update',
message: error,
})
}
})
}}
>
<SwitchAccessShortcut />
Skip to next due date
</MenuItem>
<MenuItem onClick={() => setIsChangeAssigneeModalOpen(true)}>
<RecordVoiceOver />
Delegate to someone else
</MenuItem>
<Divider />
<MenuItem onClick={() => navigate(`/chores/${chore.id}/history`)}>
<ManageSearch />
History
</MenuItem>
<Divider />
<MenuItem onClick={() => setIsChangeDueDateModalOpen(true)}>
<MoreTime />
Change due date
</MenuItem>
<MenuItem onClick={() => setIsNFCModalOpen(true)}>
<Nfc />
Write to NFC
</MenuItem>
<MenuItem onClick={handleEdit}>
<Edit />
Edit
</MenuItem>
<MenuItem onClick={handleClone}>
<CopyAll />
Clone
</MenuItem>
<MenuItem onClick={handleView}>
<ViewCarousel />
View
</MenuItem>
<MenuItem onClick={handleArchive} color='neutral'>
{chore.isActive ? <Archive /> : <Unarchive />}
{chore.isActive ? 'Archive' : 'Unarchive'}
</MenuItem>
<Divider />
<MenuItem onClick={handleDelete} color='danger'>
<Delete />
Delete
</MenuItem>
</Menu>
</Box>
{/* All modals (same as original) */}
<DateModal
isOpen={isChangeDueDateModalOpen}
key={'changeDueDate' + chore.id}
current={chore.nextDueDate}
title={`Change due date`}
onClose={() => setIsChangeDueDateModalOpen(false)}
onSave={handleChangeDueDate}
/>
<DateModal
isOpen={isCompleteWithPastDateModalOpen}
key={'completedInPast' + chore.id}
current={chore.nextDueDate}
title={`Save Chore that you completed in the past`}
onClose={() => setIsCompleteWithPastDateModalOpen(false)}
onSave={handleCompleteWithPastDate}
/>
<SelectModal
isOpen={isChangeAssigneeModalOpen}
options={performers}
displayKey='displayName'
title={`Delegate to someone else`}
placeholder={'Select a performer'}
onClose={() => setIsChangeAssigneeModalOpen(false)}
onSave={selected => handleAssigneChange(selected.id)}
/>
{confirmModelConfig?.isOpen && (
<ConfirmationModal config={confirmModelConfig} />
)}
<TextModal
isOpen={isCompleteWithNoteModalOpen}
title='Add note to attach to this completion:'
onClose={() => setIsCompleteWithNoteModalOpen(false)}
okText={'Complete'}
onSave={handleCompleteWithNote}
/>
<WriteNFCModal
config={{
isOpen: isNFCModalOpen,
url: `${window.location.origin}/chores/${chore.id}`,
onClose: () => setIsNFCModalOpen(false),
}}
/>
{/* Snackbar for pending completion */}
<Snackbar
open={isPendingCompletion}
endDecorator={
<Button
onClick={() => {
if (timeoutId) {
clearTimeout(timeoutId)
setIsPendingCompletion(false)
setTimeoutId(null)
setSecondsLeftToCancel(null)
}
}}
size='sm'
variant='outlined'
color='primary'
startDecorator={<CancelScheduleSend />}
>
Cancel
</Button>
}
>
<Typography level='body-xs' textAlign={'center'}>
Task will be marked as completed in {secondsLeftToCancel} seconds
</Typography>
</Snackbar>
</Box>
)
}
export default CompactChoreCard

View File

@@ -9,6 +9,8 @@ import {
Sort,
Style,
Unarchive,
ViewAgenda,
ViewModule,
} from '@mui/icons-material'
import {
Accordion,
@@ -42,6 +44,7 @@ import Priorities from '../../utils/Priorities'
import LoadingComponent from '../components/Loading'
import { useLabels } from '../Labels/LabelQueries'
import ChoreCard from './ChoreCard'
import CompactChoreCard from './CompactChoreCard'
import IconButtonWithMenu from './IconButtonWithMenu'
import { ChoreFilters, ChoresGrouper, ChoreSorter } from '../../utils/Chores'
@@ -81,6 +84,9 @@ const MyChores = () => {
const [searchTerm, setSearchTerm] = useState('')
const [performers, setPerformers] = useState([])
const [anchorEl, setAnchorEl] = useState(null)
const [isCompactView, setIsCompactView] = useState(
localStorage.getItem('choreCardViewMode') === 'compact',
)
const menuRef = useRef(null)
const Navigate = useNavigate()
const { data: userLabels, isLoading: userLabelsLoading } = useLabels()
@@ -205,6 +211,28 @@ const MyChores = () => {
localStorage.setItem('selectedChoreFilter', value)
}
const toggleViewMode = () => {
const newMode = !isCompactView
setIsCompactView(newMode)
localStorage.setItem('choreCardViewMode', newMode ? 'compact' : 'default')
}
// Helper function to render the appropriate card component
const renderChoreCard = (chore, key) => {
const CardComponent = isCompactView ? CompactChoreCard : ChoreCard
return (
<CardComponent
key={key || chore.id}
chore={chore}
onChoreUpdate={handleChoreUpdated}
onChoreRemove={handleChoreDeleted}
performers={performers}
userLabels={userLabels}
onChipClick={handleLabelFiltering}
/>
)
}
const updateChores = newChore => {
const newChores = chores
newChores.push(newChore)
@@ -486,6 +514,24 @@ const MyChores = () => {
}}
mouseClickHandler={handleMenuOutsideClick}
/>
{/* View Mode Toggle Button */}
<IconButton
variant='outlined'
color='neutral'
size='sm'
sx={{
height: 32,
width: 32,
borderRadius: '50%',
}}
onClick={toggleViewMode}
title={
isCompactView ? 'Switch to Card View' : 'Switch to Compact View'
}
>
{isCompactView ? <ViewModule /> : <ViewAgenda />}
</IconButton>
</Box>
{showSearchFilter && (
<div className='flex gap-4'>
@@ -626,56 +672,46 @@ const MyChores = () => {
Current Filter: {searchFilter}
</Chip>
)}
{filteredChores.length === 0 &&
archivedChores==null &&
(
<Box
sx={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
flexDirection: 'column',
height: '50vh',
}}
>
<EditCalendar
sx={{
fontSize: '4rem',
// color: 'text.disabled',
mb: 1,
}}
/>
<Typography level='title-md' gutterBottom>
Nothing scheduled
</Typography>
{chores.length > 0 && (
<>
<Button
onClick={() => {
setFilteredChores(chores)
setSearchTerm('')
}}
variant='outlined'
color='neutral'
>
Reset filters
</Button>
</>
)}
</Box>,
)}
{(searchTerm?.length > 0 || searchFilter !== 'All') &&
filteredChores.map(chore => (
<ChoreCard
key={`filtered-${chore.id} `}
chore={chore}
onChoreUpdate={handleChoreUpdated}
onChoreRemove={handleChoreDeleted}
performers={performers}
userLabels={userLabels}
onChipClick={handleLabelFiltering}
{filteredChores.length === 0 && archivedChores == null && (
<Box
sx={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
flexDirection: 'column',
height: '50vh',
}}
>
<EditCalendar
sx={{
fontSize: '4rem',
// color: 'text.disabled',
mb: 1,
}}
/>
))}
<Typography level='title-md' gutterBottom>
Nothing scheduled
</Typography>
{chores.length > 0 && (
<>
<Button
onClick={() => {
setFilteredChores(chores)
setSearchTerm('')
}}
variant='outlined'
color='neutral'
>
Reset filters
</Button>
</>
)}
</Box>
)}
{(searchTerm?.length > 0 || searchFilter !== 'All') &&
filteredChores.map(chore =>
renderChoreCard(chore, `filtered-${chore.id}`),
)}
{searchTerm.length === 0 && searchFilter === 'All' && (
<AccordionGroup transition='0.2s ease' disableDivider>
{choreSections.map((section, index) => {
@@ -735,17 +771,7 @@ const MyChores = () => {
my: 0,
}}
>
{section.content?.map(chore => (
<ChoreCard
key={chore.id}
chore={chore}
onChoreUpdate={handleChoreUpdated}
onChoreRemove={handleChoreDeleted}
performers={performers}
userLabels={userLabels}
onChipClick={handleLabelFiltering}
/>
))}
{section.content?.map(chore => renderChoreCard(chore))}
</AccordionDetails>
</Accordion>
)
@@ -797,17 +823,7 @@ const MyChores = () => {
</Chip>
</Divider>
{archivedChores?.map(chore => (
<ChoreCard
key={chore.id}
chore={chore}
onChoreUpdate={handleChoreUpdated}
onChoreRemove={handleChoreDeleted}
performers={performers}
userLabels={userLabels}
onChipClick={handleLabelFiltering}
/>
))}
{archivedChores?.map(chore => renderChoreCard(chore))}
</>
)}
</Box>

View File

@@ -24,7 +24,7 @@ const APITokenSettings = () => {
const [tokens, setTokens] = useState([])
const [isGetTokenNameModalOpen, setIsGetTokenNameModalOpen] = useState(false)
const [showTokenId, setShowTokenId] = useState(null)
const { userProfile, setUserProfile } = useContext(UserContext)
const { userProfile } = useContext(UserContext)
useEffect(() => {
GetLongLiveTokens().then(resp => {
resp.json().then(data => {
@@ -56,9 +56,16 @@ const APITokenSettings = () => {
chores
</Typography>
{!isPlusAccount(userProfile) && (
<Chip variant='soft' color='warning'>
Not available in Basic Plan
</Chip>
<>
<Chip variant='soft' color='warning'>
Plus Feature
</Chip>
<Typography level='body-sm' color='warning' sx={{ mt: 1 }}>
API tokens are not available in the Basic plan. Upgrade to Plus to
generate API tokens for integrating with external systems and
automating your tasks.
</Typography>
</>
)}
{tokens.map(token => (

View File

@@ -422,9 +422,15 @@ const Settings = () => {
</Typography>
<Typography level='body-md' mt={-1}>
Webhooks allow you to send real-time notifications to other
services when events happen in your Circle. Use the webhook URL
below to
services when events happen in your Circle. Configure a webhook
URL to receive real-time updates.
</Typography>
{!isPlusAccount(userProfile) && (
<Typography level='body-sm' color='warning' sx={{ mt: 1 }}>
Webhook notifications are not available in the Basic plan.
Upgrade to Plus to receive real-time updates via webhooks.
</Typography>
)}
<FormControl sx={{ mt: 1 }}>
<Checkbox
checked={webhookURL !== null}
@@ -448,7 +454,7 @@ const Settings = () => {
Enable webhook notifications for tasks and things updates.{' '}
{userProfile && !isPlusAccount(userProfile) && (
<Chip variant='soft' color='warning'>
Not available in Basic Plan
Plus Feature
</Chip>
)}
</FormHelperText>

View File

@@ -1,22 +1,34 @@
import { Capacitor } from '@capacitor/core'
import { Button, Card, Divider, LinearProgress, Typography } from '@mui/joy'
import { useEffect, useState } from 'react'
import {
Button,
Card,
Chip,
Divider,
LinearProgress,
Typography,
} from '@mui/joy'
import { useContext, useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { UserContext } from '../../contexts/UserContext'
import { GetStorageUsage } from '../../utils/Fetcher'
import { isPlusAccount } from '../../utils/Helpers'
const StorageSettings = () => {
const Navigate = useNavigate()
const { userProfile } = useContext(UserContext)
const [usage, setUsage] = useState({ used: 0, total: 0 })
const [loading, setLoading] = useState(true)
useEffect(() => {
GetStorageUsage().then(resp => {
resp.json().then(data => {
setUsage(data.res)
setLoading(false)
if (isPlusAccount(userProfile)) {
GetStorageUsage().then(resp => {
resp.json().then(data => {
setUsage(data.res)
setLoading(false)
})
})
})
}, [])
}
}, [userProfile])
const percent =
usage.total > 0 ? Math.round((usage.used / usage.total) * 100) : 0
@@ -30,13 +42,42 @@ const StorageSettings = () => {
<Card className='p-4' sx={{ maxWidth: 500, mb: 2 }}>
<Typography level='title-md' sx={{ mb: 1 }}>
Server Storage Usage
{!isPlusAccount(userProfile) && (
<Chip variant='soft' color='warning' sx={{ ml: 1 }}>
Plus Feature
</Chip>
)}
</Typography>
<Typography level='body-sm' sx={{ mb: 1 }}>
This is the storage used by your account on our servers (e.g. files,
images, and data you have uploaded).
</Typography>
{loading ? (
<Typography level='body-xs'>Loading...</Typography>
{!isPlusAccount(userProfile) ? (
<>
<LinearProgress
determinate
value={0}
sx={{
mb: 1,
opacity: 0.4,
'& .MuiLinearProgress-bar': {
backgroundColor: 'var(--joy-palette-neutral-400)',
},
}}
/>
<Typography level='body-xs' sx={{ opacity: 0.6, mb: 1 }}>
-- MB used / -- MB total (--)
</Typography>
<Typography level='body-sm' color='warning'>
Server storage monitoring is not available in the Basic plan.
Upgrade to Plus to track your server storage usage.
</Typography>
</>
) : loading ? (
<>
<LinearProgress sx={{ mb: 1 }} />
<Typography level='body-xs'>Loading...</Typography>
</>
) : (
<>
<LinearProgress determinate value={percent} sx={{ mb: 1 }} />