Refactor duration handling and enhance chore management features
- Replace hardcoded time units with TIME_UNITS constant in NotificationTemplate. - Introduce DurationInput component for reusable duration selection in ChoreEdit and AddTaskModal. - Update chore queries to refetch on window focus. - Adjust completion window logic in ChoreView and HistoryCard for better deadline handling. - Implement smart insights filter restoration in MyChores. - Add MyChoreHeader component for improved filter display. - Enhance activity notes display with truncation and modal view in ActivitiesCard.
This commit is contained in:
@@ -14,12 +14,9 @@ import Select from '@mui/joy/Select'
|
||||
import Typography from '@mui/joy/Typography'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { NOTIFICATION_TYPE, TASK_COLOR } from '../utils/Colors'
|
||||
import { TIME_UNITS } from '../utils/DurationUtils'
|
||||
|
||||
const timeUnits = [
|
||||
{ label: 'Mins', value: 'm' },
|
||||
{ label: 'Hours', value: 'h' },
|
||||
{ label: 'Days', value: 'd' },
|
||||
]
|
||||
const timeUnits = TIME_UNITS
|
||||
|
||||
const timingOptions = [
|
||||
{ label: 'Before', value: 'before' },
|
||||
|
||||
104
src/components/common/DurationInput.jsx
Normal file
104
src/components/common/DurationInput.jsx
Normal file
@@ -0,0 +1,104 @@
|
||||
import { Add, Remove } from '@mui/icons-material'
|
||||
import { Box, IconButton, Input, Option, Select } from '@mui/joy'
|
||||
import { useEffect, useState } from 'react'
|
||||
import {
|
||||
secondsToValueAndUnit,
|
||||
TIME_UNITS,
|
||||
valueAndUnitToSeconds,
|
||||
} from '../../utils/DurationUtils'
|
||||
|
||||
/**
|
||||
* A reusable duration picker: [−] number [+] unit-select
|
||||
*
|
||||
* Props:
|
||||
* value – duration in seconds (positive integer)
|
||||
* onChange – called with new duration in seconds
|
||||
* size – Joy UI size ('sm' | 'md')
|
||||
* minValue – minimum numeric value (default 1)
|
||||
*/
|
||||
const DurationInput = ({ value, onChange, size = 'md', minValue = 1 }) => {
|
||||
const derived =
|
||||
value != null && value >= 0
|
||||
? secondsToValueAndUnit(value)
|
||||
: { value: 1, unit: 'h' }
|
||||
const [displayValue, setDisplayValue] = useState(derived.value)
|
||||
const [unit, setUnit] = useState(derived.unit)
|
||||
|
||||
useEffect(() => {
|
||||
if (value != null && value >= 0) {
|
||||
const { value: v, unit: u } = secondsToValueAndUnit(value)
|
||||
setDisplayValue(v)
|
||||
setUnit(u)
|
||||
}
|
||||
}, [value])
|
||||
|
||||
const emit = (v, u) => {
|
||||
onChange(valueAndUnitToSeconds(v, u))
|
||||
}
|
||||
|
||||
const handleDecrement = () => {
|
||||
const next = Math.max(minValue, displayValue - 1)
|
||||
setDisplayValue(next)
|
||||
emit(next, unit)
|
||||
}
|
||||
|
||||
const handleIncrement = () => {
|
||||
const next = displayValue + 1
|
||||
setDisplayValue(next)
|
||||
emit(next, unit)
|
||||
}
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', gap: 0.5, alignItems: 'center' }}>
|
||||
<IconButton
|
||||
size={size}
|
||||
variant='outlined'
|
||||
color='neutral'
|
||||
onClick={handleDecrement}
|
||||
disabled={displayValue <= minValue}
|
||||
>
|
||||
<Remove fontSize='small' />
|
||||
</IconButton>
|
||||
|
||||
<Input
|
||||
type='number'
|
||||
value={displayValue}
|
||||
size={size}
|
||||
slotProps={{ input: { min: minValue } }}
|
||||
sx={{ maxWidth: 70, textAlign: 'center' }}
|
||||
onChange={e => {
|
||||
const v = Math.max(minValue, parseInt(e.target.value) || minValue)
|
||||
setDisplayValue(v)
|
||||
emit(v, unit)
|
||||
}}
|
||||
/>
|
||||
|
||||
<IconButton
|
||||
size={size}
|
||||
variant='outlined'
|
||||
color='neutral'
|
||||
onClick={handleIncrement}
|
||||
>
|
||||
<Add fontSize='small' />
|
||||
</IconButton>
|
||||
|
||||
<Select
|
||||
value={unit}
|
||||
size={size}
|
||||
sx={{ minWidth: 90, ml: 0.5 }}
|
||||
onChange={(_, newUnit) => {
|
||||
setUnit(newUnit)
|
||||
emit(displayValue, newUnit)
|
||||
}}
|
||||
>
|
||||
{TIME_UNITS.map(u => (
|
||||
<Option key={u.value} value={u.value}>
|
||||
{u.label}
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
export default DurationInput
|
||||
@@ -25,6 +25,7 @@ import { localStore } from '../utils/LocalStore'
|
||||
export const useChores = includeArchive => {
|
||||
return useQuery({
|
||||
queryKey: ['chores', includeArchive],
|
||||
refetchOnWindowFocus: true,
|
||||
queryFn: async () => {
|
||||
const onlineChores = await GetChoresNew(includeArchive)
|
||||
|
||||
@@ -272,6 +273,7 @@ export const useChoresHistory = (initialLimit, includeMembers) => {
|
||||
export const useChoreDetails = choreId => {
|
||||
return useQuery({
|
||||
queryKey: ['choreDetails', choreId],
|
||||
refetchOnWindowFocus: true,
|
||||
queryFn: async () => {
|
||||
var onlineChore = null
|
||||
|
||||
|
||||
@@ -324,7 +324,7 @@ export const notInCompletionWindow = chore => {
|
||||
chore.completionWindow &&
|
||||
chore.completionWindow > -1 &&
|
||||
chore.nextDueDate &&
|
||||
moment().add(chore.completionWindow, 'hours') < moment(chore.nextDueDate)
|
||||
moment() < moment(chore.nextDueDate).add(-chore.completionWindow, 'seconds')
|
||||
)
|
||||
}
|
||||
export const ChoreFilters = userId => ({
|
||||
|
||||
18
src/utils/DurationUtils.js
Normal file
18
src/utils/DurationUtils.js
Normal file
@@ -0,0 +1,18 @@
|
||||
export const TIME_UNITS = [
|
||||
{ label: 'Mins', value: 'm', seconds: 60 },
|
||||
{ label: 'Hours', value: 'h', seconds: 3600 },
|
||||
{ label: 'Days', value: 'd', seconds: 86400 },
|
||||
]
|
||||
|
||||
export function secondsToValueAndUnit(totalSeconds) {
|
||||
if (totalSeconds % 86400 === 0)
|
||||
return { value: totalSeconds / 86400, unit: 'd' }
|
||||
if (totalSeconds % 3600 === 0)
|
||||
return { value: totalSeconds / 3600, unit: 'h' }
|
||||
return { value: Math.round(totalSeconds / 60), unit: 'm' }
|
||||
}
|
||||
|
||||
export function valueAndUnitToSeconds(value, unit) {
|
||||
const unitInfo = TIME_UNITS.find(u => u.value === unit)
|
||||
return value * (unitInfo?.seconds ?? 1)
|
||||
}
|
||||
@@ -19,12 +19,12 @@ import {
|
||||
RadioGroup,
|
||||
Select,
|
||||
Sheet,
|
||||
Switch,
|
||||
Typography,
|
||||
} from '@mui/joy'
|
||||
import moment from 'moment'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useNavigate, useParams, useSearchParams } from 'react-router-dom'
|
||||
import DurationInput from '../../components/common/DurationInput'
|
||||
import KeyboardShortcutHint from '../../components/common/KeyboardShortcutHint'
|
||||
import NotificationTemplate from '../../components/NotificationTemplate.jsx'
|
||||
import {
|
||||
@@ -97,9 +97,7 @@ const ChoreEdit = () => {
|
||||
const [isPrivate, setIsPrivate] = useState(false)
|
||||
const [subTasks, setSubTasks] = useState(null)
|
||||
const [completionWindow, setCompletionWindow] = useState(-1)
|
||||
const [deadline, setDeadline] = useState(null)
|
||||
const [deadlineOffset, setDeadlineOffset] = useState(-1)
|
||||
const [deadlineUnit, setDeadlineUnit] = useState('hours')
|
||||
const [allUserThings, setAllUserThings] = useState([])
|
||||
const [thingTrigger, setThingTrigger] = useState(null)
|
||||
const [isThingValid, setIsThingValid] = useState(false)
|
||||
@@ -352,6 +350,7 @@ const ChoreEdit = () => {
|
||||
completionWindow:
|
||||
// if completionWindow is -1 then set it to null or dueDate is null
|
||||
completionWindow < 0 || dueDate === null ? null : completionWindow,
|
||||
deadlineOffset: deadlineOffset < 0 ? null : deadlineOffset,
|
||||
priority: priority,
|
||||
projectId: projectId === 'default' ? null : projectId,
|
||||
}
|
||||
@@ -477,6 +476,11 @@ const ChoreEdit = () => {
|
||||
? data.res.completionWindow
|
||||
: -1,
|
||||
)
|
||||
setDeadlineOffset(
|
||||
data.res.deadlineOffset && data.res.deadlineOffset > -1
|
||||
? data.res.deadlineOffset
|
||||
: -1,
|
||||
)
|
||||
|
||||
setLabelsV2(data.res.labelsV2)
|
||||
|
||||
@@ -1174,181 +1178,88 @@ const ChoreEdit = () => {
|
||||
|
||||
{dueDate && (
|
||||
<Box mb={3}>
|
||||
<Typography level='h4'>Completion Window</Typography>
|
||||
<FormControl orientation='horizontal'>
|
||||
<Switch
|
||||
checked={completionWindow != -1}
|
||||
onClick={event => {
|
||||
event.preventDefault()
|
||||
if (completionWindow != -1) {
|
||||
setCompletionWindow(-1)
|
||||
} else {
|
||||
setCompletionWindow(1)
|
||||
}
|
||||
}}
|
||||
color={completionWindow !== -1 ? 'success' : 'neutral'}
|
||||
variant={completionWindow !== -1 ? 'solid' : 'outlined'}
|
||||
sx={{
|
||||
mr: 2,
|
||||
}}
|
||||
/>
|
||||
<div>
|
||||
<Typography level='body-md'>
|
||||
Completion window (hours)
|
||||
</Typography>
|
||||
<FormHelperText sx={{ mt: 0 }}>
|
||||
{"Set a time window that task can't be completed before"}
|
||||
</FormHelperText>
|
||||
</div>
|
||||
</FormControl>
|
||||
{completionWindow != -1 && (
|
||||
<Card variant='outlined'>
|
||||
<Box
|
||||
sx={{
|
||||
mt: 0,
|
||||
ml: 4,
|
||||
}}
|
||||
>
|
||||
<Typography level='body-sm'>Hours:</Typography>
|
||||
<Input
|
||||
type='number'
|
||||
value={completionWindow}
|
||||
sx={{ maxWidth: 100 }}
|
||||
slotProps={{
|
||||
input: {
|
||||
min: 0,
|
||||
max: 24 * 7,
|
||||
},
|
||||
}}
|
||||
placeholder='Hours'
|
||||
onChange={e => {
|
||||
setCompletionWindow(parseInt(e.target.value))
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Card>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{dueDate && (
|
||||
<Box mb={3}>
|
||||
<Typography level='h4'>Deadline</Typography>
|
||||
<Typography level='h4'>Task Window</Typography>
|
||||
<Typography level='body-md'>
|
||||
When should this task be considered expired?
|
||||
Define when this task can be completed and when it expires
|
||||
</Typography>
|
||||
|
||||
{/* One-time tasks: Date picker */}
|
||||
{['once', 'no_repeat'].includes(frequencyType) ? (
|
||||
<FormControl sx={{ mt: 1 }}>
|
||||
<Checkbox
|
||||
onChange={e => {
|
||||
if (e.target.checked) {
|
||||
// Set deadline to 24 hours after due date by default
|
||||
const deadlineDate = moment(dueDate)
|
||||
.add(1, 'day')
|
||||
.format('YYYY-MM-DDTHH:mm:00')
|
||||
setDeadline(deadlineDate)
|
||||
} else {
|
||||
setDeadline(null)
|
||||
}
|
||||
}}
|
||||
checked={deadline !== null}
|
||||
overlay
|
||||
label='Set a deadline for this task'
|
||||
{/* Available From (Completion Window) */}
|
||||
<FormControl sx={{ mt: 1 }}>
|
||||
<Checkbox
|
||||
checked={completionWindow !== -1}
|
||||
onChange={e => {
|
||||
if (e.target.checked) {
|
||||
setCompletionWindow(3600) // default 1 hour in seconds
|
||||
} else {
|
||||
setCompletionWindow(-1)
|
||||
}
|
||||
}}
|
||||
overlay
|
||||
label='Set earliest completion time'
|
||||
/>
|
||||
<FormHelperText>
|
||||
Task becomes available to complete X hours before the due date
|
||||
</FormHelperText>
|
||||
</FormControl>
|
||||
|
||||
{completionWindow !== -1 && (
|
||||
<Box
|
||||
sx={{
|
||||
mt: 1,
|
||||
ml: 4,
|
||||
display: 'flex',
|
||||
gap: 1,
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<DurationInput
|
||||
value={completionWindow}
|
||||
onChange={setCompletionWindow}
|
||||
size='sm'
|
||||
minValue={0}
|
||||
/>
|
||||
<FormHelperText>
|
||||
Task will be considered expired after this date
|
||||
</FormHelperText>
|
||||
</FormControl>
|
||||
) : (
|
||||
/* Recurring tasks: Offset input */
|
||||
<FormControl sx={{ mt: 1 }}>
|
||||
<Checkbox
|
||||
onChange={e => {
|
||||
if (e.target.checked) {
|
||||
setDeadlineOffset(24) // Default to 24 hours
|
||||
} else {
|
||||
setDeadlineOffset(-1)
|
||||
}
|
||||
}}
|
||||
checked={deadlineOffset !== -1}
|
||||
overlay
|
||||
label='Set a deadline for this task'
|
||||
/>
|
||||
<FormHelperText>
|
||||
Task will be considered expired after the specified time from
|
||||
due date
|
||||
</FormHelperText>
|
||||
</FormControl>
|
||||
<Typography level='body-sm'>before due date</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Date picker for one-time tasks */}
|
||||
{deadline && ['once', 'no_repeat'].includes(frequencyType) && (
|
||||
<Card variant='outlined' sx={{ mt: 2 }}>
|
||||
<Box sx={{ p: 2 }}>
|
||||
<Typography level='body-sm' mb={1}>
|
||||
Deadline Date:
|
||||
</Typography>
|
||||
<Input
|
||||
type='datetime-local'
|
||||
value={deadline}
|
||||
onChange={e => setDeadline(e.target.value)}
|
||||
slotProps={{
|
||||
input: {
|
||||
min: dueDate, // Deadline cannot be before due date
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Card>
|
||||
)}
|
||||
{/* Expires After (Deadline) */}
|
||||
<FormControl sx={{ mt: 2 }}>
|
||||
<Checkbox
|
||||
checked={deadlineOffset !== -1}
|
||||
onChange={e => {
|
||||
if (e.target.checked) {
|
||||
setDeadlineOffset(86400) // default 1 day in seconds
|
||||
} else {
|
||||
setDeadlineOffset(-1)
|
||||
}
|
||||
}}
|
||||
overlay
|
||||
label='Set a deadline'
|
||||
/>
|
||||
<FormHelperText>
|
||||
Task will be considered expired after the due date
|
||||
</FormHelperText>
|
||||
</FormControl>
|
||||
|
||||
{/* Offset input for recurring tasks */}
|
||||
{deadlineOffset !== -1 &&
|
||||
!['once', 'no_repeat'].includes(frequencyType) && (
|
||||
<Card variant='outlined' sx={{ mt: 2 }}>
|
||||
<Box
|
||||
sx={{ p: 2, display: 'flex', gap: 2, alignItems: 'end' }}
|
||||
>
|
||||
<Box>
|
||||
<Typography level='body-sm' mb={1}>
|
||||
Time after due date:
|
||||
</Typography>
|
||||
<Input
|
||||
type='number'
|
||||
value={deadlineOffset}
|
||||
sx={{ maxWidth: 100 }}
|
||||
slotProps={{
|
||||
input: {
|
||||
min: 1,
|
||||
max: 720, // Max 30 days in hours
|
||||
},
|
||||
}}
|
||||
placeholder='Time'
|
||||
onChange={e => {
|
||||
setDeadlineOffset(parseInt(e.target.value) || 1)
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography level='body-sm' mb={1}>
|
||||
Unit:
|
||||
</Typography>
|
||||
<Select
|
||||
value={deadlineUnit}
|
||||
onChange={(event, newValue) =>
|
||||
setDeadlineUnit(newValue)
|
||||
}
|
||||
sx={{ minWidth: 100 }}
|
||||
>
|
||||
<Option value='hours'>Hours</Option>
|
||||
<Option value='days'>Days</Option>
|
||||
</Select>
|
||||
</Box>
|
||||
</Box>
|
||||
</Card>
|
||||
)}
|
||||
{deadlineOffset !== -1 && (
|
||||
<Box
|
||||
sx={{
|
||||
mt: 1,
|
||||
ml: 4,
|
||||
display: 'flex',
|
||||
gap: 1,
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<DurationInput
|
||||
value={deadlineOffset}
|
||||
onChange={setDeadlineOffset}
|
||||
size='sm'
|
||||
minValue={0}
|
||||
/>
|
||||
<Typography level='body-sm'>after due date</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
|
||||
@@ -149,10 +149,10 @@ const ChoreView = () => {
|
||||
'N/A'
|
||||
}`,
|
||||
subtext: ` Last: ${
|
||||
chore.lastCompletedDate
|
||||
chore.lastCompletedDate && chore.lastCompletedBy
|
||||
? performers.find(p => p.userId === chore.lastCompletedBy)
|
||||
?.displayName
|
||||
: '--'
|
||||
: 'N/A'
|
||||
}`,
|
||||
},
|
||||
{
|
||||
@@ -167,6 +167,11 @@ const ChoreView = () => {
|
||||
? moment(chore.lastCompletedDate).fromNow()
|
||||
: 'N/A'
|
||||
}`,
|
||||
|
||||
subtext2:
|
||||
chore.deadlineOffset > 0 && chore.nextDueDate
|
||||
? `Deadline: ${moment(chore.nextDueDate).add(chore.deadlineOffset, 'seconds').fromNow()}`
|
||||
: null,
|
||||
},
|
||||
{
|
||||
size: 6,
|
||||
@@ -518,7 +523,10 @@ const ChoreView = () => {
|
||||
handleAction={action => {
|
||||
if (action === 'pause') {
|
||||
handleChorePause()
|
||||
} else if (action === 'resume') {
|
||||
} else if (
|
||||
action === 'resume' &&
|
||||
!notInCompletionWindow(chore)
|
||||
) {
|
||||
handleChoreStart()
|
||||
}
|
||||
}}
|
||||
@@ -576,6 +584,14 @@ const ChoreView = () => {
|
||||
>
|
||||
{card.subtext}
|
||||
</Typography>
|
||||
{card.subtext2 && (
|
||||
<Typography
|
||||
level='body-sm'
|
||||
sx={{ color: 'danger.plainColor', lineHeight: 1.5 }}
|
||||
>
|
||||
{card.subtext2}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -1034,8 +1050,9 @@ const ChoreView = () => {
|
||||
})
|
||||
}}
|
||||
disabled={
|
||||
chore.lastCompletedDate !== null &&
|
||||
chore.frequencyType === 'once'
|
||||
notInCompletionWindow(chore) ||
|
||||
(chore.lastCompletedDate !== null &&
|
||||
chore.frequencyType === 'once')
|
||||
}
|
||||
startDecorator={<SwitchAccessShortcut />}
|
||||
sx={{
|
||||
@@ -1047,12 +1064,25 @@ const ChoreView = () => {
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
{notInCompletionWindow(chore) && (
|
||||
<Typography
|
||||
level='body-sm'
|
||||
sx={{ color: 'warning.plainColor', textAlign: 'center', mb: 1 }}
|
||||
>
|
||||
Available to complete starting{' '}
|
||||
{moment(chore.nextDueDate)
|
||||
.subtract(chore.completionWindow, 'seconds')
|
||||
.format('MM/DD/YYYY hh:mm A')}
|
||||
</Typography>
|
||||
)}
|
||||
{/* Timer Button - Show split button when timer is active, regular button otherwise */}
|
||||
{[ChoreStatus.ACTIVE, ChoreStatus.PAUSED].includes(chore.status) ? (
|
||||
<TimerSplitButton
|
||||
disabled={
|
||||
chore.lastCompletedDate !== null &&
|
||||
chore.frequencyType === 'once'
|
||||
(chore.status === ChoreStatus.PAUSED &&
|
||||
notInCompletionWindow(chore)) ||
|
||||
(chore.lastCompletedDate !== null &&
|
||||
chore.frequencyType === 'once')
|
||||
}
|
||||
chore={chore}
|
||||
onAction={action => {
|
||||
@@ -1078,8 +1108,9 @@ const ChoreView = () => {
|
||||
variant='soft'
|
||||
color='success'
|
||||
disabled={
|
||||
chore.lastCompletedDate !== null &&
|
||||
chore.frequencyType === 'once'
|
||||
notInCompletionWindow(chore) ||
|
||||
(chore.lastCompletedDate !== null &&
|
||||
chore.frequencyType === 'once')
|
||||
}
|
||||
startDecorator={<PlayArrow />}
|
||||
sx={{
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
Chip,
|
||||
Divider,
|
||||
IconButton,
|
||||
Link,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemContent,
|
||||
@@ -25,16 +26,31 @@ import {
|
||||
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'
|
||||
|
||||
const ActivityItem = ({ activity, members }) => {
|
||||
const ActivityItem = ({ activity, members, onViewNote }) => {
|
||||
// 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)
|
||||
@@ -177,7 +193,7 @@ const ActivityItem = ({ activity, members }) => {
|
||||
></Box>
|
||||
|
||||
{/* Notes */}
|
||||
{activity.notes && (
|
||||
{plainTextNotes && (
|
||||
<Box sx={{ mt: 0.5, ml: 2.5 }}>
|
||||
<Typography
|
||||
level='body-xs'
|
||||
@@ -189,8 +205,36 @@ const ActivityItem = ({ activity, members }) => {
|
||||
color: 'text.secondary',
|
||||
}}
|
||||
>
|
||||
<Notes sx={{ fontSize: 14, mt: 0.1 }} />
|
||||
{activity.notes}
|
||||
<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',
|
||||
}}
|
||||
>
|
||||
Show more
|
||||
</Link>
|
||||
)}
|
||||
</Box>
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
@@ -217,6 +261,8 @@ const groupActivitiesByDate = activities => {
|
||||
}
|
||||
|
||||
const ActivitiesCard = ({ title = 'Recent Activities' }) => {
|
||||
const [noteViewerConfig, setNoteViewerConfig] = useState({ isOpen: false })
|
||||
|
||||
// Use hooks to fetch data
|
||||
const {
|
||||
data: choresData,
|
||||
@@ -430,6 +476,14 @@ const ActivitiesCard = ({ title = 'Recent Activities' }) => {
|
||||
key={activity.id}
|
||||
activity={activity}
|
||||
members={members}
|
||||
onViewNote={notes => {
|
||||
setNoteViewerConfig({
|
||||
isOpen: true,
|
||||
title: `Note - ${activity.choreName}`,
|
||||
content: notes,
|
||||
onClose: () => setNoteViewerConfig({ isOpen: false }),
|
||||
})
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</List>
|
||||
@@ -437,6 +491,8 @@ const ActivitiesCard = ({ title = 'Recent Activities' }) => {
|
||||
)
|
||||
})}
|
||||
</Box>
|
||||
|
||||
<NoteViewerModal config={noteViewerConfig} />
|
||||
</Sheet>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -62,6 +62,7 @@ import ChoreListView from './ChoreListView.jsx'
|
||||
import ChoreModals from './components/ChoreModals'
|
||||
import FilterSection from './components/FilterSection'
|
||||
import MultiSelectToolbar from './components/MultiSelectToolbar'
|
||||
import MyChoreHeader from './components/MyChoreHeader'
|
||||
import SearchBar from './components/SearchBar'
|
||||
import { useChoreActions } from './hooks/useChoreActions'
|
||||
import { useChoreFilters } from './hooks/useChoreFilters'
|
||||
@@ -76,6 +77,7 @@ import {
|
||||
} from './LocalNotificationScheduler'
|
||||
import NotificationAccessSnackbar from './NotificationAccessSnackbar'
|
||||
import Sidepanel from './Sidepanel'
|
||||
import { INSIGHT_FILTER_DEFS } from './SmartInsightsCard'
|
||||
import SortAndGrouping from './SortAndGrouping'
|
||||
|
||||
const MyChores = () => {
|
||||
@@ -167,6 +169,7 @@ const MyChores = () => {
|
||||
activeFilter,
|
||||
activeFilterId,
|
||||
tempFilter,
|
||||
tempFilterMeta,
|
||||
filteredChores: customFilteredChores,
|
||||
applyCustomFilter,
|
||||
clearActiveFilter,
|
||||
@@ -376,6 +379,18 @@ const MyChores = () => {
|
||||
|
||||
const oldFilter = searchParams.get('filter')
|
||||
|
||||
// Restore smart insight temp filter from URL (e.g. on page reload)
|
||||
// Insight IDs are strings (e.g. 'overdue'), saved filter IDs are numeric
|
||||
if (
|
||||
filterId &&
|
||||
INSIGHT_FILTER_DEFS[filterId] &&
|
||||
tempFilterMeta?.id !== filterId
|
||||
) {
|
||||
const def = INSIGHT_FILTER_DEFS[filterId]
|
||||
applyTempFilter(def.filter, { id: filterId, name: def.name })
|
||||
return
|
||||
}
|
||||
|
||||
// If filterId is no longer in URL but filter is still active in state, clear it
|
||||
if (!filterId && !oldFilter && activeFilterId) {
|
||||
clearActiveFilter()
|
||||
@@ -422,6 +437,7 @@ const MyChores = () => {
|
||||
activeFilterId,
|
||||
savedFilters,
|
||||
applyCustomFilter,
|
||||
applyTempFilter,
|
||||
clearActiveFilter,
|
||||
selectedProject,
|
||||
projectFilteredChores,
|
||||
@@ -431,6 +447,35 @@ const MyChores = () => {
|
||||
setSelectedCalendarDate,
|
||||
])
|
||||
|
||||
// Sync tempFilterMeta (smart insight) → URL using filterId param
|
||||
// Insight IDs are strings (e.g. 'overdue') so they don't conflict with numeric saved filter IDs
|
||||
useEffect(() => {
|
||||
const insightId = tempFilterMeta?.id
|
||||
const params = new URLSearchParams(searchParams)
|
||||
const currentFilterId = params.get('filterId')
|
||||
|
||||
if (insightId && currentFilterId !== insightId) {
|
||||
params.delete('filter_id')
|
||||
params.delete('filter')
|
||||
params.set('filterId', insightId)
|
||||
Navigate(
|
||||
{ pathname: '/chores', search: params.toString() },
|
||||
{ replace: true },
|
||||
)
|
||||
} else if (
|
||||
!insightId &&
|
||||
currentFilterId &&
|
||||
INSIGHT_FILTER_DEFS[currentFilterId]
|
||||
) {
|
||||
// Only clear filterId if it was set by an insight (not a numeric saved filter)
|
||||
params.delete('filterId')
|
||||
Navigate(
|
||||
{ pathname: '/chores', search: params.toString() },
|
||||
{ replace: true },
|
||||
)
|
||||
}
|
||||
}, [tempFilterMeta?.id, searchParams])
|
||||
|
||||
const {
|
||||
handleChoreAction,
|
||||
handleChangeDueDate,
|
||||
@@ -830,6 +875,13 @@ const MyChores = () => {
|
||||
}}
|
||||
>
|
||||
<Container maxWidth='md'>
|
||||
<MyChoreHeader
|
||||
activeFilterId={activeFilterId}
|
||||
activeFilter={activeFilter}
|
||||
selectedProject={selectedProject}
|
||||
tempFilter={tempFilter}
|
||||
tempFilterMeta={tempFilterMeta}
|
||||
/>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
|
||||
72
src/views/Chores/components/MyChoreHeader.jsx
Normal file
72
src/views/Chores/components/MyChoreHeader.jsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import { FilterAlt } from '@mui/icons-material'
|
||||
import { Box, Stack, Typography } from '@mui/joy'
|
||||
import { getIconComponent } from '../../../utils/ProjectIcons.jsx'
|
||||
|
||||
const MyChoreHeader = ({
|
||||
activeFilterId,
|
||||
activeFilter,
|
||||
selectedProject,
|
||||
tempFilter,
|
||||
tempFilterMeta,
|
||||
}) => {
|
||||
if (
|
||||
!activeFilterId &&
|
||||
!tempFilter &&
|
||||
(!selectedProject || selectedProject.id === 'default')
|
||||
)
|
||||
return null
|
||||
|
||||
const renderIcon = () => {
|
||||
if (tempFilter) {
|
||||
return tempFilterMeta?.icon ? (
|
||||
<Box sx={{ fontSize: '2rem', display: 'flex', alignItems: 'center' }}>
|
||||
{tempFilterMeta.icon}
|
||||
</Box>
|
||||
) : (
|
||||
<FilterAlt sx={{ fontSize: '2rem', color: 'primary.main' }} />
|
||||
)
|
||||
}
|
||||
if (activeFilterId) {
|
||||
return <FilterAlt sx={{ fontSize: '2rem', color: 'primary.main' }} />
|
||||
}
|
||||
if (selectedProject) {
|
||||
const iconValue = selectedProject.icon || 'FolderOpen'
|
||||
const IconComponent = getIconComponent(iconValue)
|
||||
return (
|
||||
<IconComponent
|
||||
sx={{
|
||||
fontSize: 32,
|
||||
color: selectedProject.color || 'primary.main',
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const name = tempFilter
|
||||
? tempFilterMeta?.name || 'Smart Filter'
|
||||
: activeFilter?.name || selectedProject?.name
|
||||
|
||||
const description = tempFilter
|
||||
? tempFilterMeta?.description
|
||||
: activeFilter?.description || selectedProject?.description
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 2 }}>
|
||||
{renderIcon()}
|
||||
<Stack sx={{ flex: 1 }}>
|
||||
<Typography level='h3' sx={{ fontWeight: 'lg', color: 'text.primary' }}>
|
||||
{name}
|
||||
</Typography>
|
||||
{description && (
|
||||
<Typography level='body-sm' sx={{ color: 'text.secondary' }}>
|
||||
{description}
|
||||
</Typography>
|
||||
)}
|
||||
</Stack>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
export default MyChoreHeader
|
||||
@@ -26,6 +26,7 @@ export const useCustomFilters = (chores, membersData, labels, projects) => {
|
||||
|
||||
const [activeFilterId, setActiveFilterId] = useState(null)
|
||||
const [tempFilter, setTempFilter] = useState(null)
|
||||
const [tempFilterMeta, setTempFilterMeta] = useState(null)
|
||||
|
||||
const context = useMemo(
|
||||
() => ({
|
||||
@@ -95,15 +96,18 @@ export const useCustomFilters = (chores, membersData, labels, projects) => {
|
||||
const clearActiveFilter = useCallback(() => {
|
||||
setActiveFilterId(null)
|
||||
setTempFilter(null)
|
||||
setTempFilterMeta(null)
|
||||
}, [])
|
||||
|
||||
const applyTempFilter = useCallback(filter => {
|
||||
const applyTempFilter = useCallback((filter, meta = null) => {
|
||||
setTempFilter(filter)
|
||||
setTempFilterMeta(meta)
|
||||
setActiveFilterId(null)
|
||||
}, [])
|
||||
|
||||
const clearTempFilter = useCallback(() => {
|
||||
setTempFilter(null)
|
||||
setTempFilterMeta(null)
|
||||
}, [])
|
||||
|
||||
const saveFilter = useCallback(
|
||||
@@ -256,6 +260,7 @@ export const useCustomFilters = (chores, membersData, labels, projects) => {
|
||||
activeFilter,
|
||||
activeFilterId,
|
||||
tempFilter,
|
||||
tempFilterMeta,
|
||||
filteredChores,
|
||||
applyCustomFilter,
|
||||
clearActiveFilter,
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
MoreVert,
|
||||
Person,
|
||||
Redo,
|
||||
RunningWithErrors,
|
||||
ThumbDown,
|
||||
Timelapse,
|
||||
Toll,
|
||||
@@ -16,9 +17,10 @@ import moment from 'moment'
|
||||
import { TASK_COLOR } from '../../utils/Colors.jsx'
|
||||
|
||||
const getCompletedChip = historyEntry => {
|
||||
if (historyEntry.status === 0) {
|
||||
if (historyEntry.status === 0 || historyEntry.status === 5) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (!historyEntry.dueDate) {
|
||||
return null
|
||||
// <Chip
|
||||
@@ -123,6 +125,7 @@ const HistoryCard = ({
|
||||
2: { icon: <Redo />, color: 'warning' }, // Skipped
|
||||
3: { icon: <HourglassEmpty />, color: 'neutral' }, // Pending Approval
|
||||
4: { icon: <ThumbDown />, color: 'danger' }, // Rejected
|
||||
5: { icon: <RunningWithErrors />, color: 'danger' }, // Missed
|
||||
}
|
||||
|
||||
const config = statusMap[historyEntry.status] || statusMap[1]
|
||||
@@ -187,14 +190,17 @@ const HistoryCard = ({
|
||||
? 'Pending Approval'
|
||||
: historyEntry.status === 4
|
||||
? 'Rejected'
|
||||
: 'Completed'}
|
||||
: historyEntry.status === 5
|
||||
? 'Missed'
|
||||
: 'Completed'}
|
||||
</Typography>
|
||||
|
||||
<Chip size='sm' startDecorator={<EventNote />}>
|
||||
{moment(
|
||||
historyEntry.performedAt || historyEntry.updatedAt,
|
||||
).format('MMM DD, h:mm A')}
|
||||
</Chip>
|
||||
{historyEntry.performedAt && (
|
||||
<Chip size='sm' startDecorator={<EventNote />}>
|
||||
{moment(
|
||||
historyEntry.performedAt || historyEntry.updatedAt,
|
||||
).format('MMM DD, h:mm A')}
|
||||
</Chip>
|
||||
)}
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 0.5 }}>
|
||||
{getCompletedChip(historyEntry)}
|
||||
@@ -231,16 +237,21 @@ const HistoryCard = ({
|
||||
mt: 0.5,
|
||||
}}
|
||||
>
|
||||
<Chip
|
||||
size='sm'
|
||||
variant='solid'
|
||||
color='success'
|
||||
startDecorator={
|
||||
<Avatar src={performer?.image} alt={performer?.displayName} />
|
||||
}
|
||||
>
|
||||
{performer?.displayName || 'Unknown'}
|
||||
</Chip>
|
||||
{performer && (
|
||||
<Chip
|
||||
size='sm'
|
||||
variant='solid'
|
||||
color='success'
|
||||
startDecorator={
|
||||
<Avatar
|
||||
src={performer?.image}
|
||||
alt={performer?.displayName}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{performer?.displayName || 'Unknown'}
|
||||
</Chip>
|
||||
)}
|
||||
|
||||
{historyEntry.completedBy !== historyEntry.assignedTo &&
|
||||
assignedTo && (
|
||||
@@ -260,7 +271,11 @@ const HistoryCard = ({
|
||||
variant='plain'
|
||||
color='neutral'
|
||||
startDecorator={<EventNote />}
|
||||
sx={{ maxWidth: '120px', overflow: 'hidden', cursor: 'pointer' }}
|
||||
sx={{
|
||||
maxWidth: '120px',
|
||||
overflow: 'hidden',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
onClick={e => {
|
||||
e.stopPropagation()
|
||||
onViewNote?.(historyEntry.notes)
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
} from './CustomParsers'
|
||||
import SmartTaskTitleInput from './SmartTaskTitleInput'
|
||||
|
||||
import DurationInput from '../../components/common/DurationInput'
|
||||
import KeyboardShortcutHint from '../../components/common/KeyboardShortcutHint'
|
||||
import NotificationTemplate from '../../components/NotificationTemplate'
|
||||
import LearnMoreButton from './LearnMore'
|
||||
@@ -90,6 +91,8 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
|
||||
const [hasDescription, setHasDescription] = useState(false)
|
||||
const [hasSubTasks, setHasSubTasks] = useState(false)
|
||||
const [hasNotifications, setHasNotifications] = useState(false)
|
||||
const [hasDeadline, setHasDeadline] = useState(false)
|
||||
const [deadlineOffset, setDeadlineOffset] = useState(-1)
|
||||
const [showKeyboardShortcuts, setShowKeyboardShortcuts] = useState(false)
|
||||
const [projectId, setProjectId] = useState(getInitialProject())
|
||||
|
||||
@@ -491,6 +494,8 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
|
||||
setLabelsV2([])
|
||||
setAssignees([])
|
||||
setProjectId(getInitialProject())
|
||||
setHasDeadline(false)
|
||||
setDeadlineOffset(-1)
|
||||
}
|
||||
|
||||
const createChore = () => {
|
||||
@@ -529,6 +534,7 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
|
||||
labelsV2: labelsV2,
|
||||
priority: priority ? Number(priority) : 0,
|
||||
points: points > -1 ? points : null,
|
||||
deadlineOffset: deadlineOffset < 0 ? null : deadlineOffset,
|
||||
status: 0,
|
||||
frequencyType: 'once',
|
||||
frequencyMetadata: {},
|
||||
@@ -798,6 +804,19 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
|
||||
Edit Notifications
|
||||
</Button>
|
||||
)}
|
||||
{!hasDeadline && dueDate && (
|
||||
<Button
|
||||
startDecorator={<Add />}
|
||||
variant='plain'
|
||||
size='sm'
|
||||
onClick={() => {
|
||||
setHasDeadline(true)
|
||||
setDeadlineOffset(86400)
|
||||
}}
|
||||
>
|
||||
Set Deadline
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{hasDescription && (
|
||||
@@ -959,6 +978,27 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
|
||||
)}
|
||||
</Box>
|
||||
</FormControl> */}
|
||||
{hasDeadline && dueDate && (
|
||||
<Box
|
||||
sx={{
|
||||
flexDirection: 'column',
|
||||
alignItems: 'start',
|
||||
}}
|
||||
>
|
||||
<Typography level='body-sm'>Deadline</Typography>
|
||||
<Box
|
||||
sx={{ display: 'flex', alignItems: 'center', gap: 1, mt: 0.5 }}
|
||||
>
|
||||
<DurationInput
|
||||
value={deadlineOffset}
|
||||
onChange={setDeadlineOffset}
|
||||
size='sm'
|
||||
minValue={0}
|
||||
/>
|
||||
<Typography level='body-sm'>after due date</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
{hasNotifications && dueDate && (
|
||||
<Box
|
||||
sx={{
|
||||
|
||||
@@ -139,7 +139,7 @@ const NavBar = () => {
|
||||
}
|
||||
if (
|
||||
['/chores', '/'].includes(location.pathname) &&
|
||||
!searchParams.get('filter')
|
||||
!searchParams.get('filterId')
|
||||
) {
|
||||
return menuRounded
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user