Merge branch 'main' into feature/internationalization-support

This commit is contained in:
Mohamad Tarbin
2026-04-05 20:51:29 -04:00
committed by GitHub
24 changed files with 584 additions and 374 deletions

View File

@@ -20,12 +20,9 @@ As an avid for open-source, I was eager to create a solution that could benefit
- Recurring Tasks: Schedule tasks to repeat daily, weekly, monthly, or yearly, with flexible customization options. - Recurring Tasks: Schedule tasks to repeat daily, weekly, monthly, or yearly, with flexible customization options.
- Progress Tracking: Track the completion status of tasks and view historical data. - Progress Tracking: Track the completion status of tasks and view historical data.
## Installation ## Development Environment
1. Clone the repository: Follow the full instructions here: https://github.com/donetick/donetick?tab=readme-ov-file#development-environment
2. Navigate to the project directory: `cd frontend`
3. Download dependency `npm install`
4. Run locally `npm start`
## Contributing ## Contributing
@@ -41,7 +38,7 @@ Contributions are welcome! If you would like to contribute to Donetick, please f
Donetick is a work in progress and has been a fantastic learning experience for me as I've honed my React skills,I'm looking for collaborators to help improve and refine the Donetick. Feel free to open PR or suggest changes. Donetick is a work in progress and has been a fantastic learning experience for me as I've honed my React skills,I'm looking for collaborators to help improve and refine the Donetick. Feel free to open PR or suggest changes.
## Plans : ## Plans:
My goal is to expand Donetick by offering a hosted infrastructure option. This will make it even easier for users to access and utilize Donetick's features without the need for self-hosting. My goal is to expand Donetick by offering a hosted infrastructure option. This will make it even easier for users to access and utilize Donetick's features without the need for self-hosting.

View File

@@ -12,7 +12,7 @@ import Input from '@mui/joy/Input'
import Option from '@mui/joy/Option' import Option from '@mui/joy/Option'
import Select from '@mui/joy/Select' import Select from '@mui/joy/Select'
import Typography from '@mui/joy/Typography' import Typography from '@mui/joy/Typography'
import { useCallback, useEffect, useState } from 'react' import { useCallback, useEffect, useState, useRef } from 'react'
import { NOTIFICATION_TYPE, TASK_COLOR } from '../utils/Colors' import { NOTIFICATION_TYPE, TASK_COLOR } from '../utils/Colors'
import { TIME_UNITS } from '../utils/DurationUtils' import { TIME_UNITS } from '../utils/DurationUtils'
@@ -73,6 +73,9 @@ const NotificationTemplate = ({
[], [],
) )
const notificationsRef = useRef(notifications)
const [draftValues, setDraftValues] = useState({})
const [error, setError] = useState(null) const [error, setError] = useState(null)
const [showSaveDefault, setShowSaveDefault] = useState(false) const [showSaveDefault, setShowSaveDefault] = useState(false)
// Create a map of notification indices for timeline display // Create a map of notification indices for timeline display
@@ -114,7 +117,6 @@ const NotificationTemplate = ({
// Sort notifications and update the index mapping // Sort notifications and update the index mapping
useEffect(() => { useEffect(() => {
updateNotificationIndices() updateNotificationIndices()
setError(null)
}, [updateNotificationIndices]) }, [updateNotificationIndices])
// Notify parent component of changes including the template name // Notify parent component of changes including the template name
@@ -125,8 +127,8 @@ const NotificationTemplate = ({
}, [notifications, onChange]) }, [notifications, onChange])
// Validates if a notification configuration already exists // Validates if a notification configuration already exists
const isDuplicate = (notification, currentIdx = -1) => { const isDuplicate = (notification, currentIdx = -1, list = notifications) => {
return notifications.some((n, idx) => { return list.some((n, idx) => {
if (idx === currentIdx) return false if (idx === currentIdx) return false
return ( return (
@@ -136,6 +138,26 @@ const NotificationTemplate = ({
}) })
} }
const getSmartSuggestion = type => {
let suggestions = []
if (type === 'reminder' || type === 'before') {
suggestions = [
{ value: -1, unit: 'd' },
{ value: -3, unit: 'h' },
{ value: -30, unit: 'm' },
]
} else if (type === 'followup' || type === 'after') {
suggestions = [
{ value: 1, unit: 'd' },
{ value: 3, unit: 'd' },
{ value: 7, unit: 'd' },
]
}
return suggestions.find(
suggestion => !isDuplicate(suggestion, -1, notificationsRef.current),
)
}
const handleChange = (idx, field, value) => { const handleChange = (idx, field, value) => {
const currentNotification = notifications[idx] const currentNotification = notifications[idx]
const uiRep = getUIRepresentation(currentNotification) const uiRep = getUIRepresentation(currentNotification)
@@ -149,6 +171,15 @@ const NotificationTemplate = ({
// Reset display value when switching to "On Due" // Reset display value when switching to "On Due"
if (value === 'ondue') { if (value === 'ondue') {
updatedUIRep.displayValue = 0 updatedUIRep.displayValue = 0
} else if (Number(currentNotification.value) === 0) {
const suggestion = getSmartSuggestion(value)
if (suggestion) {
updatedUIRep.displayValue = Math.abs(suggestion.value)
updatedUIRep.unit = suggestion.unit
} else {
updatedUIRep.displayValue = 1
updatedUIRep.unit = 'h'
}
} }
} else if (field === 'displayValue') { } else if (field === 'displayValue') {
updatedUIRep.displayValue = Math.max(0, Number(value)) updatedUIRep.displayValue = Math.max(0, Number(value))
@@ -168,71 +199,41 @@ const NotificationTemplate = ({
unit: updatedUIRep.unit, unit: updatedUIRep.unit,
} }
// Check if another notification is already "On Due" (value = 0) const updated = notifications.map((n, i) =>
if (newInternalValue === 0) { i === idx ? updatedNotification : n,
const existingOnDue = notifications.findIndex( )
(n, i) => i !== idx && Number(n.value) === 0, setNotifications(updated)
) notificationsRef.current = updated
setError(null)
}
if (existingOnDue !== -1) { const handleBlur = idx => {
setError( const currentList = notificationsRef.current
'Only one notification can be set to "On Due". Please choose a different timing.', const currentNotification = currentList[idx]
)
return
}
}
if (isDuplicate(updatedNotification, idx)) { if (!currentNotification) return
if (isDuplicate(currentNotification, idx, currentList)) {
setError( setError(
'This notification setting already exists. Please use a different timing.', 'This notification setting already exists. Please use a different timing.',
) )
return return
} }
const updated = notifications.map((n, i) =>
i === idx ? updatedNotification : n,
)
setNotifications(updated)
setError(null)
} }
const addSmartNotification = type => { const addSmartNotification = type => {
if (notifications.length >= maxNotifications) return if (notifications.length >= maxNotifications) return
setShowSaveDefault(true) setShowSaveDefault(true)
let newNotification let newNotification
let suggestions = []
switch (type) {
case 'reminder':
// Suggest common reminder times that don't exist
suggestions = [
{ value: -1, unit: 'd' }, // 1 day before
{ value: -3, unit: 'h' }, // 3 hours before
{ value: -30, unit: 'm' }, // 3 days before
]
break
case 'due':
if (notifications.some(n => Number(n.value) === 0)) {
setError('Only one "Due Alert" notification is allowed.')
return
}
newNotification = { value: 0, unit: 'm' }
break
case 'followup':
suggestions = [
{ value: 1, unit: 'd' }, // 1 day after
{ value: 3, unit: 'd' }, // 3 days after
{ value: 7, unit: 'd' }, // 1 week after
]
break
}
// For reminder/followup, find first non-duplicate suggestion
if (suggestions.length > 0) {
newNotification = suggestions.find(suggestion => !isDuplicate(suggestion))
if (type === 'due') {
if (notificationsRef.current.some(n => Number(n.value) === 0)) {
setError('Only one "Due Alert" notification is allowed.')
return
}
newNotification = { value: 0, unit: 'm' }
} else {
newNotification = getSmartSuggestion(type)
if (!newNotification) { if (!newNotification) {
setError(`All common ${type} times are already configured.`) setError(`All common ${type} times are already configured.`)
return return
@@ -243,15 +244,25 @@ const NotificationTemplate = ({
const updatedNotifications = [...notifications, newNotification] const updatedNotifications = [...notifications, newNotification]
setNotifications(updatedNotifications) setNotifications(updatedNotifications)
notificationsRef.current = updatedNotifications
setError(null) setError(null)
} }
const removeNotification = idx => { const removeNotification = idx => {
const updated = notifications.filter((_, i) => i !== idx) const updated = notifications.filter((_, i) => i !== idx)
setNotifications(updated) setNotifications(updated)
notificationsRef.current = updated
setDraftValues(prev => {
const next = { ...prev }
delete next[idx]
return next
})
onChange && onChange(updated) onChange && onChange(updated)
setShowSaveDefault(true) setShowSaveDefault(true)
} }
const renderTimeline = () => { const renderTimeline = () => {
// Convert notifications to minutes for proper chronological sorting // Convert notifications to minutes for proper chronological sorting
const convertToMinutes = (value, unit) => { const convertToMinutes = (value, unit) => {
@@ -459,6 +470,11 @@ const NotificationTemplate = ({
const badgeNumber = notificationIndexMap[idx] const badgeNumber = notificationIndexMap[idx]
const uiRep = getUIRepresentation(n) const uiRep = getUIRepresentation(n)
// Check if an "On Due" notification exists anywhere else in the list
const hasOnDueElsewhere = notificationsRef.current.some(
(notif, i) => i !== idx && Number(notif.value) === 0,
)
const getNotificationColors = value => { const getNotificationColors = value => {
if (Number(value) < 0) { if (Number(value) < 0) {
return { return {
@@ -487,7 +503,7 @@ const NotificationTemplate = ({
const colors = getNotificationColors(n.value) const colors = getNotificationColors(n.value)
return ( return (
<> <Box key={idx} sx={{ position: 'relative' }}>
<Badge <Badge
badgeContent={badgeNumber} badgeContent={badgeNumber}
size={'sm'} size={'sm'}
@@ -495,7 +511,6 @@ const NotificationTemplate = ({
'--Badge-minHeight': '16px', '--Badge-minHeight': '16px',
'--Badge-fontSize': '0.7rem', '--Badge-fontSize': '0.7rem',
'--Badge-paddingX': '5px', '--Badge-paddingX': '5px',
top: 10, top: 10,
'& .MuiBadge-badge': { '& .MuiBadge-badge': {
background: colors.bgColor, background: colors.bgColor,
@@ -504,7 +519,6 @@ const NotificationTemplate = ({
}} }}
/> />
<Box <Box
key={idx}
sx={{ sx={{
mb: 1.5, mb: 1.5,
p: 2, p: 2,
@@ -548,11 +562,16 @@ const NotificationTemplate = ({
<Select <Select
value={uiRep.timing} value={uiRep.timing}
onChange={(_, value) => handleChange(idx, 'timing', value)} onChange={(_, value) => handleChange(idx, 'timing', value)}
onBlur={() => handleBlur(idx)}
sx={{ minWidth: 80 }} sx={{ minWidth: 80 }}
size={'sm'} size={'sm'}
> >
{timingOptions.map(opt => ( {timingOptions.map(opt => (
<Option key={opt.value} value={opt.value}> <Option
key={opt.value}
value={opt.value}
disabled={opt.value === 'ondue' && hasOnDueElsewhere}
>
{opt.label} {opt.label}
</Option> </Option>
))} ))}
@@ -560,11 +579,42 @@ const NotificationTemplate = ({
<Input <Input
type={'number'} type={'number'}
min={0} min={0}
value={uiRep.displayValue} value={
disabled={uiRep.timing === 'ondue'} draftValues[idx] !== undefined
onChange={e => ? draftValues[idx]
handleChange(idx, 'displayValue', e.target.value) : uiRep.displayValue
} }
disabled={uiRep.timing === 'ondue'}
onChange={e => {
const val = e.target.value
if (val.includes('-')) return
setDraftValues(prev => ({ ...prev, [idx]: val }))
}}
onKeyDown={e => {
if (['-', 'e', '+', '.'].includes(e.key)) {
e.preventDefault()
}
}}
onBlur={e => {
let val = e.target.value
const numericVal = Number(val)
val =
numericVal <= 0
? hasOnDueElsewhere
? 1
: 0
: numericVal
handleChange(idx, 'displayValue', val)
setDraftValues(prev => {
const next = { ...prev }
delete next[idx]
return next
})
handleBlur(idx)
}}
sx={{ sx={{
width: 60, width: 60,
opacity: uiRep.timing === 'ondue' ? 0.6 : 1, opacity: uiRep.timing === 'ondue' ? 0.6 : 1,
@@ -576,6 +626,7 @@ const NotificationTemplate = ({
value={n.unit} value={n.unit}
disabled={uiRep.timing === 'ondue'} disabled={uiRep.timing === 'ondue'}
onChange={(_, value) => handleChange(idx, 'unit', value)} onChange={(_, value) => handleChange(idx, 'unit', value)}
onBlur={() => handleBlur(idx)}
sx={{ sx={{
minWidth: 70, minWidth: 70,
opacity: uiRep.timing === 'ondue' ? 0.6 : 1, opacity: uiRep.timing === 'ondue' ? 0.6 : 1,
@@ -605,7 +656,7 @@ const NotificationTemplate = ({
</IconButton> </IconButton>
</Box> </Box>
</Box> </Box>
</> </Box>
) )
})} })}
<Box <Box

View File

@@ -32,6 +32,15 @@ html {
/* Prevent iOS Safari from auto-zooming on input focus (triggered when font-size < 16px) */
@supports (-webkit-touch-callout: none) {
input,
textarea,
select {
font-size: max(16px, 1em);
}
}
/* Ensure smooth transitions for dynamic content */ /* Ensure smooth transitions for dynamic content */
* { * {
box-sizing: border-box; box-sizing: border-box;

View File

@@ -34,8 +34,9 @@ export const getDueDateChipText = (nextDueDate, chore, timeFormat = 'h:mm A') =>
sameElse: `MMM D ${timeFormat}`, sameElse: `MMM D ${timeFormat}`,
} }
// if seconds and minutes set to 59, treat as no time (date only)
if (dueDate.seconds() === 59 && dueDate.minutes() === 59) { // if time is 23:59:59, treat as end-of-day (date only, no specific time)
if (dueDate.hours() === 23 && dueDate.minutes() === 59 && dueDate.seconds() === 59) {
if (diff < 0) { if (diff < 0) {
// For overdue dates, show calendar format for recent dates // For overdue dates, show calendar format for recent dates
const absDiff = Math.abs(diff) const absDiff = Math.abs(diff)

View File

@@ -16,6 +16,8 @@ export const ChoreHistoryStatus = Object.freeze({
SKIPPED: 2, SKIPPED: 2,
PENDING_APPROVAL: 3, PENDING_APPROVAL: 3,
REJECTED: 4, REJECTED: 4,
MISSED: 5,
RESCHEDULED: 6,
}) })
export const ChoreStatus = Object.freeze({ export const ChoreStatus = Object.freeze({
INACTIVE: 0, INACTIVE: 0,
@@ -324,7 +326,7 @@ export const notInCompletionWindow = chore => {
chore.completionWindow && chore.completionWindow &&
chore.completionWindow > -1 && chore.completionWindow > -1 &&
chore.nextDueDate && chore.nextDueDate &&
moment() < moment(chore.nextDueDate).add(-chore.completionWindow, 'seconds') moment() < moment(chore.nextDueDate).add(-chore.completionWindow, 'hours')
) )
} }
export const ChoreFilters = userId => ({ export const ChoreFilters = userId => ({
@@ -332,6 +334,9 @@ export const ChoreFilters = userId => ({
assigned_to_me: chore => { assigned_to_me: chore => {
return chore.assignedTo && chore.assignedTo === userId return chore.assignedTo && chore.assignedTo === userId
}, },
available_for_me: chore => {
return chore.assignedTo === null || chore.assignedTo === userId
},
assigned_to_others: chore => { assigned_to_others: chore => {
return chore.assignedTo && chore.assignedTo !== userId return chore.assignedTo && chore.assignedTo !== userId
}, },

View File

@@ -771,17 +771,19 @@ const LoginView = () => {
</Button> </Button>
)} )}
<Button {!resource?.is_user_creation_disabled && (
onClick={() => { <Button
Navigate('/signup') onClick={() => {
}} Navigate('/signup')
fullWidth }}
variant='soft' fullWidth
size='lg' variant='soft'
// sx={{ mt: 3, mb: 2 }} size='lg'
> // sx={{ mt: 3, mb: 2 }}
Create new account >
</Button> Create new account
</Button>
)}
<Box <Box
sx={{ display: 'flex', justifyContent: 'center', gap: 2, mt: 2 }} sx={{ display: 'flex', justifyContent: 'center', gap: 2, mt: 2 }}

View File

@@ -1,18 +1,23 @@
import { Add, HorizontalRule, Save } from '@mui/icons-material' import { Add, ArrowDropDown, HorizontalRule, Save } from '@mui/icons-material'
import { import {
Avatar, Avatar,
Box, Box,
Button, Button,
ButtonGroup,
Card, Card,
Checkbox, Checkbox,
Chip, Chip,
Container, Container,
Divider, Divider,
Dropdown,
FormControl, FormControl,
FormHelperText, FormHelperText,
IconButton,
Input, Input,
List, List,
ListItem, ListItem,
Menu,
MenuButton,
MenuItem, MenuItem,
Option, Option,
Radio, Radio,
@@ -291,7 +296,7 @@ const ChoreEdit = () => {
if (dueDateOnly) { if (dueDateOnly) {
const combinedDateTime = moment(`${dueDateOnly}T${defaultTime}`).format( const combinedDateTime = moment(`${dueDateOnly}T${defaultTime}`).format(
'YYYY-MM-DDTHH:mm:00', 'YYYY-MM-DDTHH:mm:59',
) )
setDueDate(combinedDateTime) setDueDate(combinedDateTime)
@@ -309,7 +314,7 @@ const ChoreEdit = () => {
if (dueDateOnly) { if (dueDateOnly) {
const endOfDay = moment(dueDateOnly) const endOfDay = moment(dueDateOnly)
.endOf('day') .endOf('day')
.format('YYYY-MM-DDTHH:mm:00') .format('YYYY-MM-DDTHH:mm:ss')
setDueDate(endOfDay) setDueDate(endOfDay)
} }
} }
@@ -558,7 +563,7 @@ const ChoreEdit = () => {
const today = moment(new Date()).format('YYYY-MM-DD') const today = moment(new Date()).format('YYYY-MM-DD')
setDueDateOnly(today) setDueDateOnly(today)
// Default to end of day // Default to end of day
setDueDate(moment(today).endOf('day').format('YYYY-MM-DDTHH:mm:00')) setDueDate(moment(today).endOf('day').format('YYYY-MM-DDTHH:mm:59'))
setUseCustomTime(false) setUseCustomTime(false)
setDueTime(null) setDueTime(null)
} }
@@ -1107,7 +1112,7 @@ const ChoreEdit = () => {
const today = moment(new Date()).format('YYYY-MM-DD') const today = moment(new Date()).format('YYYY-MM-DD')
setDueDateOnly(today) setDueDateOnly(today)
setDueDate( setDueDate(
moment(today).endOf('day').format('YYYY-MM-DDTHH:mm:00'), moment(today).endOf('day').format('YYYY-MM-DDTHH:mm:59'),
) )
setUseCustomTime(false) setUseCustomTime(false)
setDueTime(null) setDueTime(null)
@@ -1189,7 +1194,7 @@ const ChoreEdit = () => {
checked={completionWindow !== -1} checked={completionWindow !== -1}
onChange={e => { onChange={e => {
if (e.target.checked) { if (e.target.checked) {
setCompletionWindow(3600) // default 1 hour in seconds setCompletionWindow(1) // default 1 hour in seconds
} else { } else {
setCompletionWindow(-1) setCompletionWindow(-1)
} }
@@ -1203,29 +1208,38 @@ const ChoreEdit = () => {
</FormControl> </FormControl>
{completionWindow !== -1 && ( {completionWindow !== -1 && (
<Box <Card variant='outlined'>
sx={{ <Box
mt: 1, sx={{
ml: 4, mt: 0,
display: 'flex', ml: 4,
gap: 1, }}
alignItems: 'center', >
}} <Typography level='body-sm'>Hours:</Typography>
> <Input
<DurationInput type='number'
value={completionWindow} value={completionWindow}
onChange={setCompletionWindow} sx={{ maxWidth: 100 }}
size='sm' slotProps={{
minValue={0} input: {
/> min: 0,
<Typography level='body-sm'>before due date</Typography> max: 24 * 7,
</Box> },
}}
placeholder='Hours'
onChange={e => {
setCompletionWindow(parseInt(e.target.value))
}}
/>
</Box>
</Card>
)} )}
{/* Expires After (Deadline) */} {/* Expires After (Deadline) */}
<FormControl sx={{ mt: 2 }}> {/* <FormControl sx={{ mt: 2 }}>
<Checkbox <Checkbox
checked={deadlineOffset !== -1} checked={deadlineOffset !== -1}
disabled={isRolling}
onChange={e => { onChange={e => {
if (e.target.checked) { if (e.target.checked) {
setDeadlineOffset(86400) // default 1 day in seconds setDeadlineOffset(86400) // default 1 day in seconds
@@ -1237,9 +1251,11 @@ const ChoreEdit = () => {
label='Set a deadline' label='Set a deadline'
/> />
<FormHelperText> <FormHelperText>
Task will be considered expired after the due date {isRolling && !['once', 'no_repeat'].includes(frequencyType)
? 'Deadline is not available when scheduling from completion date'
: 'Task will be considered expired after the due date'}
</FormHelperText> </FormHelperText>
</FormControl> </FormControl> */}
{deadlineOffset !== -1 && ( {deadlineOffset !== -1 && (
<Box <Box
@@ -1286,7 +1302,10 @@ const ChoreEdit = () => {
<Radio <Radio
overlay overlay
checked={isRolling} checked={isRolling}
onClick={() => setIsRolling(true)} onClick={() => {
setIsRolling(true)
setDeadlineOffset(-1)
}}
label='Reschedule from completion date' label='Reschedule from completion date'
/> />
<FormHelperText> <FormHelperText>
@@ -1624,39 +1643,38 @@ const ChoreEdit = () => {
}} }}
> >
{choreId > 0 && ( {choreId > 0 && (
<> <Dropdown>
{isActive ? ( <ButtonGroup
<Button variant='outlined'
color='danger' color={isActive ? 'danger' : 'neutral'}
variant='outlined'
onClick={() => {
archiveChore.mutate(choreId)
}}
>
Archive
</Button>
) : (
<Button
color='neutral'
variant='outlined'
onClick={() => {
unarchiveChore.mutate(choreId)
}}
>
Unarchive
</Button>
)}
<Button
color='danger'
variant='solid'
onClick={() => {
// confirm before deleting:
handleDelete()
}}
> >
Delete <Button
</Button> onClick={() => {
</> isActive
? archiveChore.mutate(choreId)
: unarchiveChore.mutate(choreId)
}}
>
{isActive ? 'Archive' : 'Unarchive'}
</Button>
<MenuButton
slots={{ root: IconButton }}
slotProps={{
root: {
variant: 'outlined',
color: isActive ? 'danger' : 'neutral',
},
}}
>
<ArrowDropDown />
</MenuButton>
</ButtonGroup>
<Menu placement='top-end'>
<MenuItem color='danger' onClick={handleDelete}>
Delete
</MenuItem>
</Menu>
</Dropdown>
)} )}
<Button <Button
color='neutral' color='neutral'

View File

@@ -673,7 +673,6 @@ const ChoreView = () => {
color='neutral' color='neutral'
variant='plain' variant='plain'
fullWidth fullWidth
disabled={chore.isActive === false}
onClick={() => { onClick={() => {
navigate(`/chores/${choreId}/history`) navigate(`/chores/${choreId}/history`)
}} }}
@@ -692,7 +691,6 @@ const ChoreView = () => {
color='neutral' color='neutral'
variant='plain' variant='plain'
fullWidth fullWidth
disabled={chore.isActive === false}
sx={{ sx={{
// top right of the card: // top right of the card:
flexDirection: 'column', flexDirection: 'column',
@@ -1018,9 +1016,7 @@ const ChoreView = () => {
size='lg' size='lg'
onClick={handleTaskCompletion} onClick={handleTaskCompletion}
disabled={ disabled={
notInCompletionWindow(chore) || notInCompletionWindow(chore) || chore.isActive === false
(chore.lastCompletedDate !== null &&
chore.frequencyType === 'once')
} }
color='success' color='success'
startDecorator={<Check />} startDecorator={<Check />}
@@ -1050,9 +1046,7 @@ const ChoreView = () => {
}) })
}} }}
disabled={ disabled={
notInCompletionWindow(chore) || notInCompletionWindow(chore) || chore.isActive === false
(chore.lastCompletedDate !== null &&
chore.frequencyType === 'once')
} }
startDecorator={<SwitchAccessShortcut />} startDecorator={<SwitchAccessShortcut />}
sx={{ sx={{
@@ -1071,7 +1065,7 @@ const ChoreView = () => {
> >
Available to complete starting{' '} Available to complete starting{' '}
{moment(chore.nextDueDate) {moment(chore.nextDueDate)
.subtract(chore.completionWindow, 'seconds') .subtract(chore.completionWindow, 'hours')
.format('MM/DD/YYYY hh:mm A')} .format('MM/DD/YYYY hh:mm A')}
</Typography> </Typography>
)} )}
@@ -1081,8 +1075,7 @@ const ChoreView = () => {
disabled={ disabled={
(chore.status === ChoreStatus.PAUSED && (chore.status === ChoreStatus.PAUSED &&
notInCompletionWindow(chore)) || notInCompletionWindow(chore)) ||
(chore.lastCompletedDate !== null && chore.isActive === false
chore.frequencyType === 'once')
} }
chore={chore} chore={chore}
onAction={action => { onAction={action => {
@@ -1108,9 +1101,7 @@ const ChoreView = () => {
variant='soft' variant='soft'
color='success' color='success'
disabled={ disabled={
notInCompletionWindow(chore) || notInCompletionWindow(chore) || chore.isActive === false
(chore.lastCompletedDate !== null &&
chore.frequencyType === 'once')
} }
startDecorator={<PlayArrow />} startDecorator={<PlayArrow />}
sx={{ sx={{

View File

@@ -76,9 +76,9 @@ const ActivityItem = ({ activity, members, onViewNote }) => {
icon: <Timelapse />, icon: <Timelapse />,
} }
} else if (activity.status === 1) { } else if (activity.status === 1) {
const wasOnTime = moment(activity.performedAt).isSameOrBefore( const wasOnTime =
moment(activity.dueDate), !activity.dueDate ||
) moment(activity.performedAt).isSameOrBefore(moment(activity.dueDate))
if (wasOnTime) { if (wasOnTime) {
return { return {

View File

@@ -80,7 +80,7 @@ const ChoreCard = ({
const getName = name => { const getName = name => {
const split = Array.from(chore.name) const split = Array.from(chore.name)
// if the first character is emoji then remove it from the name // if the first character is emoji then remove it from the name
if (/\p{Emoji}/u.test(split[0])) { if (isNaN(Number(split[0])) && /\p{Emoji}/u.test(split[0])) {
return split.slice(1).join('').trim() return split.slice(1).join('').trim()
} }
return name return name

View File

@@ -221,15 +221,12 @@ const MyChores = () => {
let choresToGroup = chores let choresToGroup = chores
if (tempFilter || activeFilterId) { if (tempFilter || activeFilterId) {
choresToGroup = customFilteredChores choresToGroup = customFilteredChores
} else if (selectedProject) { } else if (!selectedProject || selectedProject.id === 'default') {
// Otherwise, use project-filtered chores for section grouping // No project selected or default project: only show tasks without a projectId
if (selectedProject.id === 'default') { choresToGroup = chores.filter(chore => !chore.projectId)
// Default project: only show tasks without a projectId } else {
choresToGroup = chores.filter(chore => !chore.projectId) // Specific project: use the existing filter function
} else { choresToGroup = filterByProject(chores, selectedProject.id)
// Other projects: use the existing filter function
choresToGroup = filterByProject(chores, selectedProject.id)
}
} }
const sections = ChoresGrouper( const sections = ChoresGrouper(
@@ -529,10 +526,11 @@ const MyChores = () => {
onEnableMultiSelectAndSelectAll: () => { onEnableMultiSelectAndSelectAll: () => {
toggleMultiSelectMode() toggleMultiSelectMode()
setTimeout(() => { setTimeout(() => {
selectAllVisibleChores() selectAllVisibleChores(null, choreSections, openChoreSections)
}, 0) }, 0)
}, },
onSelectAll: () => selectAllVisibleChores(), onSelectAll: () =>
selectAllVisibleChores(null, choreSections, openChoreSections),
onClearSelection: clearSelection, onClearSelection: clearSelection,
onBulkComplete: handleBulkComplete, onBulkComplete: handleBulkComplete,
onBulkSkip: handleBulkSkip, onBulkSkip: handleBulkSkip,
@@ -1276,6 +1274,9 @@ const MyChores = () => {
setSearchFilter('All') setSearchFilter('All')
setSearchTerm('') setSearchTerm('')
clearActiveFilter() clearActiveFilter()
// reset project and filters :
setSelectedProjectWithCache(null)
updateFilterUrl(null, null)
}} }}
variant='outlined' variant='outlined'
color='neutral' color='neutral'

View File

@@ -317,7 +317,7 @@ const SmartInsightsCard = ({
return ( return (
<Button <Button
key={insight.id} key={insight.id}
variant={isActive ? 'solid' : 'soft'} variant={isActive ? 'outlined' : 'soft'}
color='neutral' color='neutral'
onClick={() => handleInsightClick(insight)} onClick={() => handleInsightClick(insight)}
sx={{ sx={{

View File

@@ -85,7 +85,12 @@ const SortAndGrouping = ({
{ name: 'Labels', value: 'labels' }, { name: 'Labels', value: 'labels' },
] ]
const filterItems = ['anyone', 'assigned_to_me', 'assigned_to_others'] const filterItems = [
'anyone',
'assigned_to_me',
'available_for_me',
'assigned_to_others',
]
// Total selectable items: 4 (group by) + 3 (filters) + 1 (create custom filter) = 8 // Total selectable items: 4 (group by) + 3 (filters) + 1 (create custom filter) = 8
const totalItems = groupByItems.length + filterItems.length + 1 const totalItems = groupByItems.length + filterItems.length + 1
@@ -169,6 +174,65 @@ const SortAndGrouping = ({
} }
}, []) }, [])
const MenuItem_QuickFilter = props => {
return (
<MenuItem
key={props.key}
onClick={() => {
setFilter(props.filterKey)
handleMenuClose()
}}
onMouseEnter={() => setIsKeyboardNavigating(false)}
sx={{
borderRadius: 'var(--joy-radius-sm)',
backgroundColor:
selectedFilter === props.filterKey
? 'var(--joy-palette-primary-softBg)'
: selectedIndex === props.index &&
anchorEl &&
isKeyboardNavigating
? 'var(--joy-palette-neutral-softHoverBg)'
: 'transparent',
'&:hover': {
backgroundColor:
selectedFilter === props.filterKey
? 'var(--joy-palette-primary-softBg)'
: 'var(--joy-palette-neutral-softHoverBg)',
},
}}
>
<ListItemDecorator>
<Radio
checked={selectedFilter === props.filterKey}
variant='outlined'
/>
</ListItemDecorator>
<ListItemContent>
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
}}
>
<Typography
level='body-sm'
sx={{
fontWeight: selectedFilter === props.filterKey ? 600 : 400,
color:
selectedFilter === props.filterKey
? 'var(--joy-palette-primary-600)'
: 'var(--joy-palette-text-primary)',
}}
>
{props.label}
</Typography>
</Box>
</ListItemContent>
</MenuItem>
)
}
return ( return (
<> <>
{!label && ( {!label && (
@@ -359,162 +423,33 @@ const SortAndGrouping = ({
</ListItemContent> </ListItemContent>
</MenuItem> </MenuItem>
<MenuItem <MenuItem_QuickFilter
key={`${k}-assignee-anyone`} key={`${k}-assignee-anyone`}
onClick={() => { index={4}
setFilter('anyone') filterKey='anyone'
handleMenuClose() label='Anyone'
}} />
onMouseEnter={() => setIsKeyboardNavigating(false)}
sx={{
borderRadius: 'var(--joy-radius-sm)',
backgroundColor:
selectedFilter === 'anyone'
? 'var(--joy-palette-primary-softBg)'
: selectedIndex === 4 && anchorEl && isKeyboardNavigating
? 'var(--joy-palette-neutral-softHoverBg)'
: 'transparent',
'&:hover': {
backgroundColor:
selectedFilter === 'anyone'
? 'var(--joy-palette-primary-softBg)'
: 'var(--joy-palette-neutral-softHoverBg)',
},
}}
>
<ListItemDecorator>
<Radio checked={selectedFilter === 'anyone'} variant='outlined' />
</ListItemDecorator>
<ListItemContent>
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
}}
>
<Typography
level='body-sm'
sx={{
fontWeight: selectedFilter === 'anyone' ? 600 : 400,
color:
selectedFilter === 'anyone'
? 'var(--joy-palette-primary-600)'
: 'var(--joy-palette-text-primary)',
}}
>
Anyone
</Typography>
</Box>
</ListItemContent>
</MenuItem>
<MenuItem <MenuItem_QuickFilter
key={`${k}-assignee-assigned-to-me`} key={`${k}-assignee-assigned-to-me`}
onClick={() => { index={5}
setFilter('assigned_to_me') filterKey='assigned_to_me'
handleMenuClose() label='Assigned to me'
}} />
onMouseEnter={() => setIsKeyboardNavigating(false)}
sx={{
borderRadius: 'var(--joy-radius-sm)',
backgroundColor:
selectedFilter === 'assigned_to_me'
? 'var(--joy-palette-primary-softBg)'
: selectedIndex === 5 && anchorEl && isKeyboardNavigating
? 'var(--joy-palette-neutral-softHoverBg)'
: 'transparent',
'&:hover': {
backgroundColor:
selectedFilter === 'assigned_to_me'
? 'var(--joy-palette-primary-softBg)'
: 'var(--joy-palette-neutral-softHoverBg)',
},
}}
>
<ListItemDecorator>
<Radio
checked={selectedFilter === 'assigned_to_me'}
variant='outlined'
/>
</ListItemDecorator>
<ListItemContent>
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
}}
>
<Typography
level='body-sm'
sx={{
fontWeight: selectedFilter === 'assigned_to_me' ? 600 : 400,
color:
selectedFilter === 'assigned_to_me'
? 'var(--joy-palette-primary-600)'
: 'var(--joy-palette-text-primary)',
}}
>
Assigned to me
</Typography>
</Box>
</ListItemContent>
</MenuItem>
<MenuItem <MenuItem_QuickFilter
key={`${k}-assignee-available-for-me`}
index={6}
filterKey='available_for_me'
label='Available for me'
/>
<MenuItem_QuickFilter
key={`${k}-assignee-assigned-to-others`} key={`${k}-assignee-assigned-to-others`}
onClick={() => { index={7}
setFilter('assigned_to_others') filterKey='assigned_to_others'
handleMenuClose() label='Assigned to others'
}} />
onMouseEnter={() => setIsKeyboardNavigating(false)}
sx={{
borderRadius: 'var(--joy-radius-sm)',
backgroundColor:
selectedFilter === 'assigned_to_others'
? 'var(--joy-palette-primary-softBg)'
: selectedIndex === 6 && anchorEl && isKeyboardNavigating
? 'var(--joy-palette-neutral-softHoverBg)'
: 'transparent',
'&:hover': {
backgroundColor:
selectedFilter === 'assigned_to_others'
? 'var(--joy-palette-primary-softBg)'
: 'var(--joy-palette-neutral-softHoverBg)',
},
}}
>
<ListItemDecorator>
<Radio
checked={selectedFilter === 'assigned_to_others'}
variant='outlined'
/>
</ListItemDecorator>
<ListItemContent>
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
}}
>
<Typography
level='body-sm'
sx={{
fontWeight:
selectedFilter === 'assigned_to_others' ? 600 : 400,
color:
selectedFilter === 'assigned_to_others'
? 'var(--joy-palette-primary-600)'
: 'var(--joy-palette-text-primary)',
}}
>
Assigned to others
</Typography>
</Box>
</ListItemContent>
</MenuItem>
<Divider sx={{ my: 1 }} /> <Divider sx={{ my: 1 }} />
@@ -528,7 +463,7 @@ const SortAndGrouping = ({
sx={{ sx={{
borderRadius: 'var(--joy-radius-sm)', borderRadius: 'var(--joy-radius-sm)',
backgroundColor: backgroundColor:
selectedIndex === 7 && anchorEl && isKeyboardNavigating selectedIndex === 8 && anchorEl && isKeyboardNavigating
? 'var(--joy-palette-success-softHoverBg)' ? 'var(--joy-palette-success-softHoverBg)'
: 'transparent', : 'transparent',
'&:hover': { '&:hover': {

View File

@@ -154,6 +154,18 @@ export const useChoreActions = ({
async (action, chore, extraData = {}) => { async (action, chore, extraData = {}) => {
switch (action) { switch (action) {
case 'complete': case 'complete':
// 1. Instantly hide the chore from the UI and Cache
setChores(prev => prev.filter(c => c.id !== chore.id))
setFilteredChores(prev => prev.filter(c => c.id !== chore.id))
queryClient.setQueriesData({ queryKey: ['chores'] }, oldData => {
if (!oldData || !oldData.res) return oldData;
return {
...oldData,
res: oldData.res.filter(c => c.id !== chore.id),
}
});
try { try {
const response = await MarkChoreComplete( const response = await MarkChoreComplete(
chore.id, chore.id,
@@ -162,10 +174,36 @@ export const useChoreActions = ({
null, null,
) )
if (response.ok) { if (response.ok) {
const data = await response.json() // 2. Show the success notification with Undo
updateChoreInState(data.res, 'completed') showSuccess({
message: 'Task completed',
undoAction: async () => {
try {
const undoResponse = await UndoChoreAction(chore.id)
if (undoResponse.ok) {
refetchChores()
showUndo({
title: 'Undo Successful',
message: 'Task completion has been undone.',
})
} else throw new Error('Failed to undo')
} catch (error) {
showError({
title: 'Undo Failed',
message: 'Unable to undo the action. Please try again.',
})
}
},
})
// 3. Fetch the fresh active list from the server silently
// (This brings in the next occurrence if recurring, without showing the completed one)
queryClient.invalidateQueries({ queryKey: ['chores'] })
} else {
refetchChores() // Network failed, revert to truth
} }
} catch (error) { } catch (error) {
refetchChores() // Network failed, revert to truth
if (error?.queued) { if (error?.queued) {
showError({ showError({
title: 'Update Failed', title: 'Update Failed',
@@ -259,6 +297,7 @@ export const useChoreActions = ({
c => c.id !== chore.id, c => c.id !== chore.id,
) )
setChores(newChores) setChores(newChores)
updateChoreInState(chore.id, 'deleted')
setFilteredChores(newFilteredChores) setFilteredChores(newFilteredChores)
showSuccess({ showSuccess({
title: 'Task Deleted', title: 'Task Deleted',
@@ -472,7 +511,7 @@ export const useChoreActions = ({
) )
const handleBulkComplete = useCallback(async () => { const handleBulkComplete = useCallback(async () => {
const selectedData = getSelectedChoresData() const selectedData = getSelectedChoresData(chores)
if (selectedData.length === 0) return if (selectedData.length === 0) return
setConfirmModelConfig({ setConfirmModelConfig({
@@ -532,7 +571,7 @@ export const useChoreActions = ({
}, [getSelectedChoresData, impersonatedUser, showSuccess, showError, refetchChores, clearSelection, setConfirmModelConfig]) }, [getSelectedChoresData, impersonatedUser, showSuccess, showError, refetchChores, clearSelection, setConfirmModelConfig])
const handleBulkArchive = useCallback(async () => { const handleBulkArchive = useCallback(async () => {
const selectedData = getSelectedChoresData() const selectedData = getSelectedChoresData(chores)
if (selectedData.length === 0) return if (selectedData.length === 0) return
setConfirmModelConfig({ setConfirmModelConfig({
@@ -594,7 +633,7 @@ export const useChoreActions = ({
}, [getSelectedChoresData, archiveChore, setChores, setFilteredChores, showSuccess, showError, refetchChores, clearSelection, setConfirmModelConfig]) }, [getSelectedChoresData, archiveChore, setChores, setFilteredChores, showSuccess, showError, refetchChores, clearSelection, setConfirmModelConfig])
const handleBulkDelete = useCallback(async () => { const handleBulkDelete = useCallback(async () => {
const selectedData = getSelectedChoresData() const selectedData = getSelectedChoresData(chores)
if (selectedData.length === 0) return if (selectedData.length === 0) return
setConfirmModelConfig({ setConfirmModelConfig({
@@ -654,7 +693,7 @@ export const useChoreActions = ({
}, [getSelectedChoresData, chores, filteredChores, setChores, setFilteredChores, showSuccess, showError, refetchChores, clearSelection, setConfirmModelConfig]) }, [getSelectedChoresData, chores, filteredChores, setChores, setFilteredChores, showSuccess, showError, refetchChores, clearSelection, setConfirmModelConfig])
const handleBulkSkip = useCallback(async () => { const handleBulkSkip = useCallback(async () => {
const selectedData = getSelectedChoresData() const selectedData = getSelectedChoresData(chores)
if (selectedData.length === 0) return if (selectedData.length === 0) return
setConfirmModelConfig({ setConfirmModelConfig({

View File

@@ -15,9 +15,7 @@ export const useChoreFilters = ({
) )
const projectFilteredChores = useMemo(() => { const projectFilteredChores = useMemo(() => {
if (!selectedProject) return chores if (!selectedProject || selectedProject.id === 'default') {
if (selectedProject.id === 'default') {
return chores.filter(chore => !chore.projectId) return chores.filter(chore => !chore.projectId)
} }

View File

@@ -123,7 +123,7 @@ const ChoreHistory = () => {
{ {
icon: <Checklist />, icon: <Checklist />,
text: 'All Completed', text: 'All Completed',
subtext: `${histories.filter(h => h.status === ChoreHistoryStatus.COMPLETED).length} times`, subtext: `${histories.filter(h => h.status === ChoreHistoryStatus.COMPLETED || h.status === ChoreHistoryStatus.SKIPPED).length} times`,
}, },
{ {
icon: <TrendingUp />, icon: <TrendingUp />,

View File

@@ -8,6 +8,7 @@ import {
Person, Person,
Redo, Redo,
RunningWithErrors, RunningWithErrors,
Schedule,
ThumbDown, ThumbDown,
Timelapse, Timelapse,
Toll, Toll,
@@ -18,7 +19,7 @@ import { useLocalization } from '../../contexts/LocalizationContext'
import { TASK_COLOR } from '../../utils/Colors.jsx' import { TASK_COLOR } from '../../utils/Colors.jsx'
const getCompletedChip = historyEntry => { const getCompletedChip = historyEntry => {
if (historyEntry.status === 0 || historyEntry.status === 5) { if (historyEntry.status === 0 || historyEntry.status === 5 || historyEntry.status === 6) {
return null return null
} }
@@ -128,6 +129,7 @@ const HistoryCard = ({
3: { icon: <HourglassEmpty />, color: 'neutral' }, // Pending Approval 3: { icon: <HourglassEmpty />, color: 'neutral' }, // Pending Approval
4: { icon: <ThumbDown />, color: 'danger' }, // Rejected 4: { icon: <ThumbDown />, color: 'danger' }, // Rejected
5: { icon: <RunningWithErrors />, color: 'danger' }, // Missed 5: { icon: <RunningWithErrors />, color: 'danger' }, // Missed
6: { icon: <Schedule />, color: 'warning' }, // Rescheduled
} }
const config = statusMap[historyEntry.status] || statusMap[1] const config = statusMap[historyEntry.status] || statusMap[1]
@@ -194,7 +196,9 @@ const HistoryCard = ({
? 'Rejected' ? 'Rejected'
: historyEntry.status === 5 : historyEntry.status === 5
? 'Missed' ? 'Missed'
: 'Completed'} : historyEntry.status === 6
? 'Rescheduled'
: 'Completed'}
</Typography> </Typography>
<Chip size='sm' startDecorator={<EventNote />}> <Chip size='sm' startDecorator={<EventNote />}>

View File

@@ -611,7 +611,7 @@ const NotificationSetting = () => {
<Typography level='h3'>Custom Notification</Typography> <Typography level='h3'>Custom Notification</Typography>
<Divider /> <Divider />
<Typography level='body-md'> <Typography level='body-md'>
Notificaiton through other platform like Telegram or Pushover Notification through other platform like Telegram or Pushover
</Typography> </Typography>
<FormControl orientation='horizontal'> <FormControl orientation='horizontal'>

View File

@@ -1,14 +1,16 @@
import CheckCircleIcon from '@mui/icons-material/CheckCircle'
import HourglassEmptyIcon from '@mui/icons-material/HourglassEmpty'
import ThumbDownIcon from '@mui/icons-material/ThumbDown'
import TimelapseIcon from '@mui/icons-material/Timelapse'
import { Cell, Pie, PieChart, Tooltip } from 'recharts' import { Cell, Pie, PieChart, Tooltip } from 'recharts'
import { import {
Block, AccessTime,
Check, Check,
EventBusy, EventBusy,
EventNote,
Group, Group,
HourglassEmpty,
Redo,
RunningWithErrors,
Schedule,
ThumbDown,
Timeline, Timeline,
Toll, Toll,
} from '@mui/icons-material' } from '@mui/icons-material'
@@ -34,6 +36,7 @@ import React, { useEffect, useState } from 'react'
import { useLocalization } from '../../contexts/LocalizationContext' import { useLocalization } from '../../contexts/LocalizationContext'
import { useChores, useChoresHistory } from '../../queries/ChoreQueries' import { useChores, useChoresHistory } from '../../queries/ChoreQueries'
import NoteViewerModal from '../Modals/Inputs/NoteViewerModal'
import { useCircleMembers, useUserProfile } from '../../queries/UserQueries.jsx' import { useCircleMembers, useUserProfile } from '../../queries/UserQueries.jsx'
import { ChoresGrouper } from '../../utils/Chores' import { ChoresGrouper } from '../../utils/Chores'
import { COLORS, TASK_COLOR } from '../../utils/Colors.jsx' import { COLORS, TASK_COLOR } from '../../utils/Colors.jsx'
@@ -44,7 +47,9 @@ const groupByDate = history => {
const aggregated = {} const aggregated = {}
for (let i = 0; i < history.length; i++) { for (let i = 0; i < history.length; i++) {
const item = history[i] const item = history[i]
const date = new Date(item.performedAt).toLocaleDateString() const date = new Date(
item.performedAt || item.updatedAt,
).toLocaleDateString()
if (!aggregated[date]) { if (!aggregated[date]) {
aggregated[date] = [] aggregated[date] = []
} }
@@ -53,21 +58,25 @@ const groupByDate = history => {
return aggregated return aggregated
} }
const ChoreHistoryItem = ({ time, name, points, status, performer }) => { const ChoreHistoryItem = ({ time, name, points, status, performer, notes, onViewNote }) => {
const getStatusIcon = status => { const getStatusIcon = status => {
switch (status) { switch (status) {
case 0: case 0:
return <TimelapseIcon color='primary' /> return <AccessTime color='primary' />
case 1: case 1:
return <Check color='success' /> return <Check color='success' />
case 2: case 2:
return <Block color='warning' /> return <Redo color='warning' />
case 3: case 3:
return <HourglassEmptyIcon color='action' /> return <HourglassEmpty color='neutral' />
case 4: case 4:
return <ThumbDownIcon color='error' /> return <ThumbDown color='error' />
case 5:
return <RunningWithErrors color='error' />
case 6:
return <Schedule color='warning' />
default: default:
return <CheckCircleIcon color='success' /> return <Check color='success' />
} }
} }
@@ -114,15 +123,36 @@ const ChoreHistoryItem = ({ time, name, points, status, performer }) => {
{`${points} points`} {`${points} points`}
</Chip> </Chip>
)} )}
{notes && (
<Chip
size='sm'
variant='soft'
color='neutral'
startDecorator={<EventNote />}
sx={{ cursor: 'pointer' }}
onClick={e => {
e.stopPropagation()
onViewNote?.(notes)
}}
>
Note
</Chip>
)}
</Box> </Box>
</Stack> </Stack>
) )
} }
const ChoreHistoryTimeline = ({ history }) => {
const ChoreHistoryTimeline = ({ history, onViewNote }) => {
const { fmt } = useLocalization() const { fmt } = useLocalization()
const groupedHistory = groupByDate(history) const groupedHistory = groupByDate(history)
const sortedEntries = Object.entries(groupedHistory).sort(
([a], [b]) => new Date(b) - new Date(a),
)
return ( return (
<Container sx={{ p: 2 }}> <Container sx={{ p: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 2 }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 2 }}>
@@ -143,10 +173,15 @@ const ChoreHistoryTimeline = ({ history }) => {
<> <>
<ChoreHistoryItem <ChoreHistoryItem
key={record.id} key={record.id}
time={fmt.time(record.performedAt)}
time={fmt.time(
record.performedAt || record.updatedAt,
)}
name={record.choreName} name={record.choreName}
points={record.points} points={record.points}
status={record.status} status={record.status}
notes={record.notes}
onViewNote={onViewNote}
/> />
</> </>
))} ))}
@@ -366,6 +401,7 @@ const UserActivites = () => {
const [selectedHistory, setSelectedHistory] = React.useState([]) const [selectedHistory, setSelectedHistory] = React.useState([])
const [enrichedHistory, setEnrichedHistory] = React.useState([]) const [enrichedHistory, setEnrichedHistory] = React.useState([])
const [selectedChart, setSelectedChart] = React.useState('history') const [selectedChart, setSelectedChart] = React.useState('history')
const [noteViewerConfig, setNoteViewerConfig] = useState({ isOpen: false })
const [historyPieChartData, setHistoryPieChartData] = React.useState([]) const [historyPieChartData, setHistoryPieChartData] = React.useState([])
const [choreDuePieChartData, setChoreDuePieChartData] = React.useState([]) const [choreDuePieChartData, setChoreDuePieChartData] = React.useState([])
@@ -1066,7 +1102,17 @@ const UserActivites = () => {
> >
{/* Left Side - Timeline (Mobile: Full width, Desktop: Flexible) */} {/* Left Side - Timeline (Mobile: Full width, Desktop: Flexible) */}
<Box sx={{ flex: 1, minWidth: 0, width: '100%' }}> <Box sx={{ flex: 1, minWidth: 0, width: '100%' }}>
<ChoreHistoryTimeline history={selectedHistory} /> <ChoreHistoryTimeline
history={selectedHistory}
onViewNote={notes => {
setNoteViewerConfig({
isOpen: true,
title: 'Note',
content: notes,
onClose: () => setNoteViewerConfig({ isOpen: false }),
})
}}
/>
</Box> </Box>
{/* Right Sidebar - Charts (Mobile: Full width, Desktop: Fixed width + sticky) */} {/* Right Sidebar - Charts (Mobile: Full width, Desktop: Fixed width + sticky) */}
@@ -1216,6 +1262,7 @@ const UserActivites = () => {
</Box> </Box>
</> </>
)} )}
<NoteViewerModal config={noteViewerConfig} />
</Container> </Container>
) )
} }

View File

@@ -1,5 +1,14 @@
import { Add, EditNotifications } from '@mui/icons-material' import { Add, EditNotifications } from '@mui/icons-material'
import { Box, Button, Input, Option, Select, Typography } from '@mui/joy' import {
Box,
Button,
Checkbox,
FormHelperText,
Input,
Option,
Select,
Typography,
} from '@mui/joy'
import { FormControl } from '@mui/material' import { FormControl } from '@mui/material'
import * as chrono from 'chrono-node' import * as chrono from 'chrono-node'
import moment from 'moment' import moment from 'moment'
@@ -20,7 +29,6 @@ import {
} from './CustomParsers' } from './CustomParsers'
import SmartTaskTitleInput from './SmartTaskTitleInput' import SmartTaskTitleInput from './SmartTaskTitleInput'
import DurationInput from '../../components/common/DurationInput'
import KeyboardShortcutHint from '../../components/common/KeyboardShortcutHint' import KeyboardShortcutHint from '../../components/common/KeyboardShortcutHint'
import NotificationTemplate from '../../components/NotificationTemplate' import NotificationTemplate from '../../components/NotificationTemplate'
import LearnMoreButton from './LearnMore' import LearnMoreButton from './LearnMore'
@@ -93,6 +101,9 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
const [hasNotifications, setHasNotifications] = useState(false) const [hasNotifications, setHasNotifications] = useState(false)
const [hasDeadline, setHasDeadline] = useState(false) const [hasDeadline, setHasDeadline] = useState(false)
const [deadlineOffset, setDeadlineOffset] = useState(-1) const [deadlineOffset, setDeadlineOffset] = useState(-1)
const [dueDateOnly, setDueDateOnly] = useState(null)
const [dueTime, setDueTime] = useState(null)
const [useCustomTime, setUseCustomTime] = useState(false)
const [showKeyboardShortcuts, setShowKeyboardShortcuts] = useState(false) const [showKeyboardShortcuts, setShowKeyboardShortcuts] = useState(false)
const [projectId, setProjectId] = useState(getInitialProject()) const [projectId, setProjectId] = useState(getInitialProject())
@@ -136,7 +147,11 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
!dueDate !dueDate
) { ) {
// add due date: // add due date:
setDueDate(moment().add(1, 'day').format('YYYY-MM-DDTHH:00:00')) const tomorrow = moment().add(1, 'day')
setDueDateOnly(tomorrow.format('YYYY-MM-DD'))
setDueDate(tomorrow.endOf('day').format('YYYY-MM-DDTHH:mm:59'))
setUseCustomTime(false)
setDueTime(null)
setShowKeyboardShortcuts(false) setShowKeyboardShortcuts(false)
} }
// Enter key to create task // Enter key to create task
@@ -380,9 +395,24 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
setFrequencyHumanReadable(repeat.name) setFrequencyHumanReadable(repeat.name)
} }
const syncDueDateStates = parsedDate => {
const m = moment(parsedDate)
const dateOnly = m.format('YYYY-MM-DD')
const timeOnly = m.format('HH:mm')
setDueDateOnly(dateOnly)
setDueDate(m.format('YYYY-MM-DDTHH:mm:ss'))
if (timeOnly !== '23:59') {
setUseCustomTime(true)
setDueTime(timeOnly)
} else {
setUseCustomTime(false)
setDueTime(null)
}
}
let dueDateHighlight = null let dueDateHighlight = null
if (dueDateParsed.result) { if (dueDateParsed.result) {
setDueDate(moment(dueDateParsed.result).format('YYYY-MM-DDTHH:mm:ss')) syncDueDateStates(dueDateParsed.result)
dueDateHighlight = dueDateParsed.highlight[0] dueDateHighlight = dueDateParsed.highlight[0]
} }
@@ -391,9 +421,7 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
// we need to reparse the date again to get the correct due date: // we need to reparse the date again to get the correct due date:
const dueDateParsedAgain = parseDueDate(sentence, chrono) const dueDateParsedAgain = parseDueDate(sentence, chrono)
if (dueDateParsedAgain.result) { if (dueDateParsedAgain.result) {
setDueDate( syncDueDateStates(dueDateParsedAgain.result)
moment(dueDateParsedAgain.result).format('YYYY-MM-DDTHH:mm:ss'),
)
} }
} }
@@ -473,6 +501,47 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
processText, processText,
]) ])
const handleDueDateChange = e => {
const dateValue = e.target.value
setDueDateOnly(dateValue)
if (useCustomTime && dueTime) {
setDueDate(
moment(`${dateValue}T${dueTime}`).format('YYYY-MM-DDTHH:mm:00'),
)
} else {
setDueDate(moment(dateValue).endOf('day').format('YYYY-MM-DDTHH:mm:ss'))
}
}
const handleDueTimeChange = e => {
const timeValue = e.target.value
setDueTime(timeValue)
if (dueDateOnly) {
setDueDate(
moment(`${dueDateOnly}T${timeValue}`).format('YYYY-MM-DDTHH:mm:00'),
)
}
}
const handleUseCustomTimeChange = checked => {
setUseCustomTime(checked)
if (checked) {
const defaultTime = dueTime || '18:00'
setDueTime(defaultTime)
if (dueDateOnly) {
setDueDate(
moment(`${dueDateOnly}T${defaultTime}`).format('YYYY-MM-DDTHH:mm:00'),
)
}
} else {
if (dueDateOnly) {
setDueDate(
moment(dueDateOnly).endOf('day').format('YYYY-MM-DDTHH:mm:ss'),
)
}
}
}
const handleEnterPressed = () => { const handleEnterPressed = () => {
createChore() createChore()
} }
@@ -496,6 +565,9 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
setProjectId(getInitialProject()) setProjectId(getInitialProject())
setHasDeadline(false) setHasDeadline(false)
setDeadlineOffset(-1) setDeadlineOffset(-1)
setDueDateOnly(null)
setDueTime(null)
setUseCustomTime(false)
} }
const createChore = () => { const createChore = () => {
@@ -526,6 +598,7 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
const chore = { const chore = {
name: taskTitle, name: taskTitle,
description: description,
assignees: finalAssignees, assignees: finalAssignees,
dueDate: dueDate ? new Date(dueDate).toISOString() : null, dueDate: dueDate ? new Date(dueDate).toISOString() : null,
assignedTo: finalAssignedTo, assignedTo: finalAssignedTo,
@@ -781,7 +854,11 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
variant='plain' variant='plain'
size='sm' size='sm'
onClick={() => { onClick={() => {
setDueDate(moment().add(1, 'day').format('YYYY-MM-DDTHH:00:00')) const tomorrow = moment().add(1, 'day')
setDueDateOnly(tomorrow.format('YYYY-MM-DD'))
setDueDate(tomorrow.endOf('day').format('YYYY-MM-DDTHH:mm:ss'))
setUseCustomTime(false)
setDueTime(null)
}} }}
endDecorator={ endDecorator={
showKeyboardShortcuts && <KeyboardShortcutHint shortcut='B' /> showKeyboardShortcuts && <KeyboardShortcutHint shortcut='B' />
@@ -804,7 +881,7 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
Edit Notifications Edit Notifications
</Button> </Button>
)} )}
{!hasDeadline && dueDate && ( {/* {!hasDeadline && dueDate && (
<Button <Button
startDecorator={<Add />} startDecorator={<Add />}
variant='plain' variant='plain'
@@ -816,7 +893,7 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
> >
Set Deadline Set Deadline
</Button> </Button>
)} )} */}
</Box> </Box>
{hasDescription && ( {hasDescription && (
@@ -871,11 +948,30 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
<FormControl> <FormControl>
<Typography level='body-sm'>Due Date</Typography> <Typography level='body-sm'>Due Date</Typography>
<Input <Input
type='datetime-local' type='date'
value={dueDate} value={dueDateOnly || ''}
onChange={e => setDueDate(e.target.value)} onChange={handleDueDateChange}
sx={{ width: '100%', fontSize: '16px' }}
/> />
<Checkbox
size='sm'
checked={useCustomTime}
onChange={e => handleUseCustomTimeChange(e.target.checked)}
label='Set a specific time'
sx={{ mt: 1 }}
/>
<FormHelperText>
{useCustomTime
? 'Task will be due at the specified time'
: 'Task will be due at the end of the day (11:59 PM)'}
</FormHelperText>
{useCustomTime && (
<Input
type='time'
value={dueTime || '18:00'}
onChange={handleDueTimeChange}
sx={{ maxWidth: 200, mt: 1 }}
/>
)}
</FormControl> </FormControl>
)} )}
</Box> </Box>
@@ -978,7 +1074,7 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
)} )}
</Box> </Box>
</FormControl> */} </FormControl> */}
{hasDeadline && dueDate && ( {/* {hasDeadline && dueDate && (
<Box <Box
sx={{ sx={{
flexDirection: 'column', flexDirection: 'column',
@@ -998,7 +1094,7 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
<Typography level='body-sm'>after due date</Typography> <Typography level='body-sm'>after due date</Typography>
</Box> </Box>
</Box> </Box>
)} )} */}
{hasNotifications && dueDate && ( {hasNotifications && dueDate && (
<Box <Box
sx={{ sx={{
@@ -1013,11 +1109,11 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
if ( if (
metadata.notifications !== notificationMetadata.templates metadata.notifications !== notificationMetadata.templates
) { ) {
const newNotificaitonMetadata = { const newNotificationMetadata = {
...notificationMetadata, ...notificationMetadata,
templates: metadata.notifications, templates: metadata.notifications,
} }
setNotificationMetadata(newNotificaitonMetadata) setNotificationMetadata(newNotificationMetadata)
} }
}} }}
value={notificationMetadata} value={notificationMetadata}

View File

@@ -722,8 +722,16 @@ export const parseDueDate = (inputSentence, chrono) => {
.replace(/\s+/g, ' ') // Replace multiple spaces with single space .replace(/\s+/g, ' ') // Replace multiple spaces with single space
.trim() .trim()
// If no specific time was mentioned, set to end of day (23:59:59)
// to indicate the date has no specific time tied to it (same convention as ChoreEdit)
let resultDate = dueDateMatch.start.date()
if (!dueDateMatch.start.isCertain('hour')) {
resultDate = new Date(resultDate)
resultDate.setHours(23, 59, 59, 0)
}
return { return {
result: dueDateMatch.start.date(), result: resultDate,
highlight: [ highlight: [
{ {
text: fullHighlightText, text: fullHighlightText,

View File

@@ -155,6 +155,7 @@ const NavBar = () => {
'/login', '/login',
'/auth/oauth2', '/auth/oauth2',
'/forgot-password', '/forgot-password',
'/password/update',
'/login/settings', '/login/settings',
'/welcome', '/welcome',
].includes(location.pathname) ].includes(location.pathname)

View File

@@ -167,6 +167,13 @@
line-height: 1.5; line-height: 1.5;
} }
/* Prevent iOS Safari from auto-zooming on focus (triggered when font-size < 16px) */
@supports (-webkit-touch-callout: none) {
.quill-root .ql-editor {
font-size: 16px;
}
}
/* Custom focus styles */ /* Custom focus styles */
.quill-root:focus-within .ql-toolbar.ql-snow { .quill-root:focus-within .ql-toolbar.ql-snow {
border-color: var(--joy-palette-primary-outlinedBorder, #1976d2); border-color: var(--joy-palette-primary-outlinedBorder, #1976d2);

View File

@@ -213,7 +213,7 @@ const SmartTaskTitleInput = ({
fontSize: 'inherit', fontSize: 'inherit',
lineHeight: 'inherit', lineHeight: 'inherit',
backgroundColor: 'transparent', backgroundColor: 'transparent',
color: mode === 'dark' ? '#cbd5e1' : '#1a202c', color: 'transparent',
caretColor: mode === 'dark' ? '#fff' : '#000', caretColor: mode === 'dark' ? '#fff' : '#000',
}} }}
/> />