Fix action button layout and enhance notification handling (#190)

* Fix approval /declien button issue. update action buttons and improve layout

* feat(notifications): enhance notification handling and default templates across components
This commit is contained in:
Mohamad Tarbin
2026-08-04 21:22:09 -04:00
committed by GitHub
parent 412faf4ad5
commit 784ffbcd00
7 changed files with 212 additions and 81 deletions

View File

@@ -63,6 +63,10 @@ function getInternalValue(timing, displayValue) {
const NotificationTemplate = ({ const NotificationTemplate = ({
maxNotifications = 5, maxNotifications = 5,
// ChoreEdit gates this editor behind its own on/off switch, so the last row
// must stay put — `notification: true` with no templates is not a valid task.
// Consumers that own an empty state themselves pass 0.
minNotifications = 1,
onChange, onChange,
value, value,
showTimeline = true, showTimeline = true,
@@ -259,7 +263,8 @@ const NotificationTemplate = ({
return next return next
}) })
onChange && onChange(updated) // No direct onChange here: consumers expect { notifications }, and the
// effect below already emits that shape once the state settles.
setShowSaveDefault(true) setShowSaveDefault(true)
} }
@@ -307,9 +312,6 @@ const NotificationTemplate = ({
return ( return (
<Box sx={{ mt: 3, mb: 2 }}> <Box sx={{ mt: 3, mb: 2 }}>
<Typography level={'body-md'} sx={{ mb: 1 }}>
Notification Timeline
</Typography>
<Box <Box
sx={{ sx={{
display: 'flex', display: 'flex',
@@ -642,7 +644,7 @@ const NotificationTemplate = ({
</Select> </Select>
<IconButton <IconButton
onClick={() => removeNotification(idx)} onClick={() => removeNotification(idx)}
disabled={notifications.length === 1} disabled={notifications.length <= minNotifications}
color={'danger'} color={'danger'}
size={'sm'} size={'sm'}
variant={'soft'} variant={'soft'}

View File

@@ -5,7 +5,7 @@ import {
Pause, Pause,
PlayArrow, PlayArrow,
Repeat, Repeat,
Schedule, ThumbDown,
ThumbUp, ThumbUp,
TimesOneMobiledata, TimesOneMobiledata,
Toll, Toll,
@@ -21,6 +21,7 @@ import {
IconButton, IconButton,
Typography, Typography,
} from '@mui/joy' } from '@mui/joy'
import { useImpersonateUser } from '../../contexts/ImpersonateUserContext.jsx' import { useImpersonateUser } from '../../contexts/ImpersonateUserContext.jsx'
import { useLocalization } from '../../contexts/LocalizationContext' import { useLocalization } from '../../contexts/LocalizationContext'
import { usePendingCommands } from '../../hooks/usePendingCommands' import { usePendingCommands } from '../../hooks/usePendingCommands'
@@ -37,16 +38,16 @@ import ChoreActionMenu from '../components/ChoreActionMenu'
import PendingBadge from '../components/PendingBadge' import PendingBadge from '../components/PendingBadge'
const ChoreCard = ({ const ChoreCard = ({
chore, chore,
performers,
sx,
viewOnly,
showActions = true,
onChipClick,
onAction,
// Multi-select props
isMultiSelectMode = false, isMultiSelectMode = false,
isSelected = false, isSelected = false,
onAction,
onChipClick,
onSelectionToggle, onSelectionToggle,
performers,
// Multi-select props
showActions = true,
sx,
viewOnly,
}) => { }) => {
const { data: userProfile } = useUserProfile() const { data: userProfile } = useUserProfile()
const { timeFormat } = useLocalization() const { timeFormat } = useLocalization()
@@ -359,27 +360,6 @@ const ChoreCard = ({
justifyContent: 'center', justifyContent: 'center',
}} }}
> >
{chore.status === 3 && (
<Chip
variant='soft'
color='neutral'
size='sm'
sx={{
mb: 1,
px: 0.75,
py: 0.5,
minHeight: 56,
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
alignItems: 'center',
gap: 0.25,
}}
>
<Schedule sx={{ fontSize: 16 }} />
<Typography level='body-xs'>Pending</Typography>
</Chip>
)}
{showActions && ( {showActions && (
<Box <Box
display='flex' display='flex'
@@ -420,29 +400,31 @@ const ChoreCard = ({
> >
<ThumbUp sx={{ fontSize: 18 }} /> <ThumbUp sx={{ fontSize: 18 }} />
</IconButton> </IconButton>
{/* <IconButton <IconButton
variant='soft' variant='soft'
color='danger' color='danger'
onClick={e => { onClick={e => {
e.stopPropagation() e.stopPropagation()
onAction('reject', chore) onAction('reject', chore)
}} }}
sx={{ sx={{
borderRadius: '50%', borderRadius: '50%',
minWidth: 40, width: 50,
height: 40, minWidth: 50,
zIndex: 1, height: 50,
transition: 'all 0.2s ease', flexShrink: 0,
'&:hover': { zIndex: 1,
transform: 'scale(1.05)', transition: 'all 0.2s ease',
}, '&:hover': {
'&:active': { transform: 'scale(1.05)',
transform: 'scale(0.95)', },
}, '&:active': {
}} transform: 'scale(0.95)',
> },
<ThumbDown sx={{ fontSize: 18 }} /> }}
</IconButton> */} >
<ThumbDown sx={{ fontSize: 18 }} />
</IconButton>
</Box> </Box>
) : ( ) : (
<IconButton <IconButton
@@ -515,6 +497,7 @@ const ChoreCard = ({
</IconButton> </IconButton>
)} )}
<ChoreActionMenu <ChoreActionMenu
variant='plain'
chore={chore} chore={chore}
onCompleteWithNote={() => onCompleteWithNote={() =>
onAction('completeWithNote', chore) onAction('completeWithNote', chore)
@@ -530,6 +513,16 @@ const ChoreCard = ({
onWriteNFC={() => onAction('writeNFC', chore)} onWriteNFC={() => onAction('writeNFC', chore)}
onNudge={() => onAction('nudge', chore)} onNudge={() => onAction('nudge', chore)}
onDelete={() => onAction('delete', chore)} onDelete={() => onAction('delete', chore)}
sx={{
width: 32,
height: 32,
color: 'text.tertiary',
flexShrink: 0,
'&:hover': {
color: 'text.secondary',
bgcolor: 'background.level1',
},
}}
/> />
</Box> </Box>
)} )}

View File

@@ -49,6 +49,23 @@ const getTimeFromTemplate = (template, relativeTime) => {
} }
return time return time
} }
// Decide whether this device's user should be notified about a chore:
// - assignedTo set -> only that user
// - no assignedTo -> everyone listed in assignees
// - no assignees -> "Anyone" mode, notify the whole circle
const shouldNotifyUser = (chore, userId) => {
if (!userId) {
return false
}
if (chore.assignedTo > 0) {
return chore.assignedTo === userId
}
if (chore.assignees?.length > 0) {
return chore.assignees.some(assignee => assignee.userId === userId)
}
return true
}
const scheduleNotificationFromTemplate = ( const scheduleNotificationFromTemplate = (
chore, chore,
userProfile, userProfile,
@@ -193,7 +210,8 @@ const scheduleChoreNotification = async (
if ( if (
chore.notification === false || chore.notification === false ||
chore.nextDueDate === null || chore.nextDueDate === null ||
chore.isActive === false chore.isActive === false ||
!shouldNotifyUser(chore, userProfile?.id)
) { ) {
continue continue
} }

View File

@@ -45,22 +45,65 @@ import ScanPanel from './ScanToTask/ScanPanel'
import SubTasks from './SubTask' import SubTasks from './SubTask'
import { buildChorePayload, parseVoiceTask } from './VoiceToTask/parseVoiceTask' import { buildChorePayload, parseVoiceTask } from './VoiceToTask/parseVoiceTask'
import VoicePanel from './VoiceToTask/VoicePanel' import VoicePanel from './VoiceToTask/VoicePanel'
// Canonical reminder template shape, shared with NotificationTemplate and
// LocalNotificationScheduler: a signed value plus 'm' | 'h' | 'd'. Negative is
// before due, positive is after, zero is on due.
const DEFAULT_NOTIFICATION_TEMPLATES = [
{ value: -1, unit: 'd' },
{ value: 0, unit: 'm' },
{ value: 1, unit: 'd' },
]
const UNIT_ALIASES = {
minute: 'm',
minutes: 'm',
hour: 'h',
hours: 'h',
day: 'd',
days: 'd',
}
// Earlier builds stored {value: 1, unit: 'days', type: 'before'}. Nothing reads
// `type`, and the scheduler's unit switch falls through on 'days', so those
// entries fired at the due time (or collided on id) instead of offsetting.
const normalizeTemplate = template => {
const unit = UNIT_ALIASES[template.unit] || template.unit
const value = Number(template.value) || 0
if (!template.type) return { value, unit }
if (template.type === 'ondue') return { value: 0, unit }
return {
value: template.type === 'before' ? -Math.abs(value) : Math.abs(value),
unit,
}
}
const getDefaultNotification = () => { const getDefaultNotification = () => {
const storedDefault = localStorage.getItem('defaultNotificationTemplate') const storedDefault = localStorage.getItem('defaultNotificationTemplate')
if (storedDefault) { if (storedDefault) {
return JSON.parse(storedDefault) try {
const parsed = JSON.parse(storedDefault)
if (Array.isArray(parsed)) {
// An empty list is a deliberate "no reminders by default", not a
// missing value — respect it instead of re-seeding.
const normalized = parsed.map(normalizeTemplate)
if (JSON.stringify(normalized) !== storedDefault) {
localStorage.setItem(
'defaultNotificationTemplate',
JSON.stringify(normalized),
)
}
return normalized
}
} catch {
// fall through and reset to the defaults below
}
} }
const defaultNotification = [
{ value: 1, unit: 'days', type: 'before' },
{ value: 0, unit: 'minutes', type: 'ondue' },
{ value: 1, unit: 'days', type: 'after' },
]
localStorage.setItem( localStorage.setItem(
'defaultNotificationTemplate', 'defaultNotificationTemplate',
JSON.stringify(defaultNotification), JSON.stringify(DEFAULT_NOTIFICATION_TEMPLATES),
) )
return defaultNotification return DEFAULT_NOTIFICATION_TEMPLATES
} }
const TaskInput = ({ onChoreUpdate, isModalOpen, onClose, initialMode }) => { const TaskInput = ({ onChoreUpdate, isModalOpen, onClose, initialMode }) => {
@@ -157,6 +200,12 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose, initialMode }) => {
isListening: false, isListening: false,
}) })
const [creatingVoiceTasks, setCreatingVoiceTasks] = useState(false) const [creatingVoiceTasks, setCreatingVoiceTasks] = useState(false)
// Reminder default the voice cards start from — read once so the array
// identity stays stable across renders of the panel
const voiceDefaultNotificationTemplates = useMemo(
() => getDefaultNotification(),
[],
)
// Same arrangement for the scan panel: it reports the action for its // Same arrangement for the scan panel: it reports the action for its
// current phase and the modal footer renders it // current phase and the modal footer renders it
const [scanState, setScanState] = useState({ const [scanState, setScanState] = useState({
@@ -558,6 +607,11 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose, initialMode }) => {
if ('priority' in overrides) setPriority(overrides.priority || 0) if ('priority' in overrides) setPriority(overrides.priority || 0)
if ('frequency' in overrides) setFrequency(overrides.frequency) if ('frequency' in overrides) setFrequency(overrides.frequency)
if ('labelIds' in overrides) setLabelsV2(overrides.labelIds || []) if ('labelIds' in overrides) setLabelsV2(overrides.labelIds || [])
if ('notificationMetadata' in overrides) {
setNotificationMetadata(
overrides.notificationMetadata || { templates: [] },
)
}
if ('assignees' in overrides || 'isAnyone' in overrides) { if ('assignees' in overrides || 'isAnyone' in overrides) {
setIsAnyoneTask(!!overrides.isAnyone) setIsAnyoneTask(!!overrides.isAnyone)
setAssignees(overrides.assignees || []) setAssignees(overrides.assignees || [])
@@ -812,24 +866,29 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose, initialMode }) => {
status: 0, status: 0,
frequencyType: 'once', frequencyType: 'once',
frequencyMetadata: {}, frequencyMetadata: {},
notification: false,
notificationMetadata: {}, notificationMetadata: {},
subTasks: subTasks?.length > 0 ? subTasks : null, subTasks: subTasks?.length > 0 ? subTasks : null,
projectId: projectId === 'default' ? null : projectId, projectId: projectId === 'default' ? null : projectId,
draftId: draftId, draftId: draftId,
} }
// Reminders are a Plus feature and only make sense when the user kept at
// least one template; without the flag the backend never schedules them.
const hasReminders =
isPlusAccount(userProfile) && notificationMetadata?.templates?.length > 0
if (frequency) { if (frequency) {
chore.frequencyType = frequency.frequencyType chore.frequencyType = frequency.frequencyType
chore.frequencyMetadata = frequency.frequencyMetadata chore.frequencyMetadata = frequency.frequencyMetadata
chore.frequency = frequency.frequency chore.frequency = frequency.frequency
if (isPlusAccount(userProfile)) {
chore.notification = true
chore.notificationMetadata = notificationMetadata
}
} }
if (!frequency && dueDate) { if (!frequency && dueDate) {
// Use RFC3339/ISO-8601 format expected by backend. // Use RFC3339/ISO-8601 format expected by backend.
chore.nextDueDate = new Date(dueDate).toISOString() chore.nextDueDate = new Date(dueDate).toISOString()
}
if (hasReminders && (frequency || dueDate)) {
chore.notification = true
chore.notificationMetadata = notificationMetadata chore.notificationMetadata = notificationMetadata
} }
@@ -1291,6 +1350,7 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose, initialMode }) => {
userLabels={voiceLabels} userLabels={voiceLabels}
members={voiceMembers} members={voiceMembers}
userProfile={userProfile} userProfile={userProfile}
defaultNotificationTemplates={voiceDefaultNotificationTemplates}
onStateChange={setVoiceState} onStateChange={setVoiceState}
/> />
)} )}

View File

@@ -135,10 +135,11 @@ const NotificationPickerField = ({
> >
<NotificationTemplate <NotificationTemplate
value={value} value={value}
minNotifications={0}
onChange={({ notifications }) => { onChange={({ notifications }) => {
latestTemplatesRef.current = notifications latestTemplatesRef.current = notifications
}} }}
showTimeline showTimeline={false}
/> />
</ResponsiveModal> </ResponsiveModal>
</> </>

View File

@@ -6,6 +6,7 @@ import {
GraphicEq, GraphicEq,
Lock, Lock,
Mic, Mic,
NotificationsNone,
Person, Person,
Repeat, Repeat,
Sell, Sell,
@@ -16,9 +17,11 @@ import { Box, Button, Chip, IconButton, Input, Typography } from '@mui/joy'
import moment from 'moment' import moment from 'moment'
import { useEffect, useMemo, useRef, useState } from 'react' import { useEffect, useMemo, useRef, useState } from 'react'
import { TASK_COLOR } from '../../../utils/Colors' import { TASK_COLOR } from '../../../utils/Colors'
import { isPlusAccount } from '../../../utils/Helpers'
import AssigneePickerField from '../AssigneePickerField' import AssigneePickerField from '../AssigneePickerField'
import DueDatePickerField from '../DueDatePickerField' import DueDatePickerField from '../DueDatePickerField'
import LabelsPickerField from '../LabelsPickerField' import LabelsPickerField from '../LabelsPickerField'
import NotificationPickerField from '../NotificationPickerField'
import PriorityPickerField from '../PriorityPickerField' import PriorityPickerField from '../PriorityPickerField'
import RepeatPickerField from '../RepeatPickerField' import RepeatPickerField from '../RepeatPickerField'
import { parseVoiceTask } from './parseVoiceTask' import { parseVoiceTask } from './parseVoiceTask'
@@ -102,7 +105,11 @@ const describeFrequency = f => {
return names[f.frequencyType] || 'Repeats' return names[f.frequencyType] || 'Repeats'
} }
const buildChips = (effective, frequencyLabel, { members, currentUserId }) => { const buildChips = (
effective,
frequencyLabel,
{ members, currentUserId, canRemind },
) => {
const chips = [] const chips = []
if (effective.dueDate) { if (effective.dueDate) {
chips.push({ chips.push({
@@ -128,6 +135,21 @@ const buildChips = (effective, frequencyLabel, { members, currentUserId }) => {
label: `P${effective.priority}`, label: `P${effective.priority}`,
}) })
} }
// Mirrors buildChorePayload: reminders only reach the backend for Plus
// accounts on a task that has something to remind against
const reminderCount = effective.notificationMetadata?.templates?.length || 0
if (
canRemind &&
reminderCount > 0 &&
(effective.dueDate || effective.frequency)
) {
chips.push({
key: 'reminders',
color: 'neutral',
icon: <NotificationsNone sx={{ fontSize: 12 }} />,
label: reminderCount > 1 ? `${reminderCount} reminders` : '1 reminder',
})
}
if (effective.points != null) { if (effective.points != null) {
chips.push({ chips.push({
key: 'points', key: 'points',
@@ -184,9 +206,17 @@ const TaskPreviewCard = ({
[segment.text, parseCtx], [segment.text, parseCtx],
) )
const overrides = useMemo(() => segment.overrides || {}, [segment.overrides]) const overrides = useMemo(() => segment.overrides || {}, [segment.overrides])
// The parser has no notion of reminders, so the account default stands in
// until the card overrides it — same fallback buildChorePayload applies
const effective = useMemo( const effective = useMemo(
() => ({ ...parsed, ...overrides }), () => ({
[parsed, overrides], ...parsed,
notificationMetadata: {
templates: parseCtx.defaultNotificationTemplates || [],
},
...overrides,
}),
[parsed, overrides, parseCtx.defaultNotificationTemplates],
) )
const frequencyLabel = const frequencyLabel =
@@ -329,6 +359,7 @@ const TaskPreviewCard = ({
{expanded && ( {expanded && (
<Box <Box
sx={{ sx={{
mt: 1,
display: 'flex', display: 'flex',
flexDirection: 'row', flexDirection: 'row',
gap: 1, gap: 1,
@@ -392,6 +423,16 @@ const TaskPreviewCard = ({
onClear={() => onPatch({ labelIds: [] })} onClear={() => onPatch({ labelIds: [] })}
labels={parseCtx.userLabels} labels={parseCtx.userLabels}
/> />
{parseCtx.canRemind && (
<NotificationPickerField
emptyDisplay='icon'
value={effective.notificationMetadata}
onChange={metadata => onPatch({ notificationMetadata: metadata })}
onClear={() =>
onPatch({ notificationMetadata: { templates: [] } })
}
/>
)}
</Box> </Box>
)} )}
</Box> </Box>
@@ -412,6 +453,7 @@ const VoicePanel = ({
userLabels = [], userLabels = [],
members = [], members = [],
userProfile, userProfile,
defaultNotificationTemplates = [],
onStateChange, onStateChange,
}) => { }) => {
const { const {
@@ -430,8 +472,14 @@ const VoicePanel = ({
const segmentsScrollRef = useRef(null) const segmentsScrollRef = useRef(null)
const parseCtx = useMemo( const parseCtx = useMemo(
() => ({ userLabels, members, currentUserId: userProfile?.id }), () => ({
[userLabels, members, userProfile?.id], userLabels,
members,
currentUserId: userProfile?.id,
canRemind: isPlusAccount(userProfile),
defaultNotificationTemplates,
}),
[userLabels, members, userProfile, defaultNotificationTemplates],
) )
const partialParsed = useMemo( const partialParsed = useMemo(

View File

@@ -177,24 +177,33 @@ export const buildChorePayload = (
status: 0, status: 0,
frequencyType: 'once', frequencyType: 'once',
frequencyMetadata: {}, frequencyMetadata: {},
notification: false,
notificationMetadata: {}, notificationMetadata: {},
subTasks: null, subTasks: null,
projectId: projectId === 'default' ? null : projectId, projectId: projectId === 'default' ? null : projectId,
draftId: generateUUID(), draftId: generateUUID(),
} }
// A per-task override from the voice card wins over the account default;
// an override of [] is a deliberate "no reminders", not a missing value.
const templates =
parsed.notificationMetadata?.templates ?? notificationTemplates
// Reminders are a Plus feature; the flag is what makes the backend schedule
// them, so metadata alone is not enough.
const hasReminders = isPlusAccount(userProfile) && templates?.length > 0
if (parsed.frequency) { if (parsed.frequency) {
chore.frequencyType = parsed.frequency.frequencyType chore.frequencyType = parsed.frequency.frequencyType
chore.frequencyMetadata = parsed.frequency.frequencyMetadata chore.frequencyMetadata = parsed.frequency.frequencyMetadata
chore.frequency = parsed.frequency.frequency chore.frequency = parsed.frequency.frequency
if (isPlusAccount(userProfile)) {
chore.notification = true
chore.notificationMetadata = { templates: notificationTemplates }
}
} }
if (!parsed.frequency && parsed.dueDate) { if (!parsed.frequency && parsed.dueDate) {
chore.nextDueDate = new Date(parsed.dueDate).toISOString() chore.nextDueDate = new Date(parsed.dueDate).toISOString()
chore.notificationMetadata = { templates: notificationTemplates } }
if (hasReminders && (parsed.frequency || parsed.dueDate)) {
chore.notification = true
chore.notificationMetadata = { templates }
} }
return chore return chore