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 = ({
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,
value,
showTimeline = true,
@@ -259,7 +263,8 @@ const NotificationTemplate = ({
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)
}
@@ -307,9 +312,6 @@ const NotificationTemplate = ({
return (
<Box sx={{ mt: 3, mb: 2 }}>
<Typography level={'body-md'} sx={{ mb: 1 }}>
Notification Timeline
</Typography>
<Box
sx={{
display: 'flex',
@@ -642,7 +644,7 @@ const NotificationTemplate = ({
</Select>
<IconButton
onClick={() => removeNotification(idx)}
disabled={notifications.length === 1}
disabled={notifications.length <= minNotifications}
color={'danger'}
size={'sm'}
variant={'soft'}

View File

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

View File

@@ -49,6 +49,23 @@ const getTimeFromTemplate = (template, relativeTime) => {
}
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 = (
chore,
userProfile,
@@ -193,7 +210,8 @@ const scheduleChoreNotification = async (
if (
chore.notification === false ||
chore.nextDueDate === null ||
chore.isActive === false
chore.isActive === false ||
!shouldNotifyUser(chore, userProfile?.id)
) {
continue
}

View File

@@ -45,22 +45,65 @@ import ScanPanel from './ScanToTask/ScanPanel'
import SubTasks from './SubTask'
import { buildChorePayload, parseVoiceTask } from './VoiceToTask/parseVoiceTask'
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 storedDefault = localStorage.getItem('defaultNotificationTemplate')
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(
'defaultNotificationTemplate',
JSON.stringify(defaultNotification),
JSON.stringify(DEFAULT_NOTIFICATION_TEMPLATES),
)
return defaultNotification
return DEFAULT_NOTIFICATION_TEMPLATES
}
const TaskInput = ({ onChoreUpdate, isModalOpen, onClose, initialMode }) => {
@@ -157,6 +200,12 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose, initialMode }) => {
isListening: 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
// current phase and the modal footer renders it
const [scanState, setScanState] = useState({
@@ -558,6 +607,11 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose, initialMode }) => {
if ('priority' in overrides) setPriority(overrides.priority || 0)
if ('frequency' in overrides) setFrequency(overrides.frequency)
if ('labelIds' in overrides) setLabelsV2(overrides.labelIds || [])
if ('notificationMetadata' in overrides) {
setNotificationMetadata(
overrides.notificationMetadata || { templates: [] },
)
}
if ('assignees' in overrides || 'isAnyone' in overrides) {
setIsAnyoneTask(!!overrides.isAnyone)
setAssignees(overrides.assignees || [])
@@ -812,24 +866,29 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose, initialMode }) => {
status: 0,
frequencyType: 'once',
frequencyMetadata: {},
notification: false,
notificationMetadata: {},
subTasks: subTasks?.length > 0 ? subTasks : null,
projectId: projectId === 'default' ? null : projectId,
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) {
chore.frequencyType = frequency.frequencyType
chore.frequencyMetadata = frequency.frequencyMetadata
chore.frequency = frequency.frequency
if (isPlusAccount(userProfile)) {
chore.notification = true
chore.notificationMetadata = notificationMetadata
}
}
if (!frequency && dueDate) {
// Use RFC3339/ISO-8601 format expected by backend.
chore.nextDueDate = new Date(dueDate).toISOString()
}
if (hasReminders && (frequency || dueDate)) {
chore.notification = true
chore.notificationMetadata = notificationMetadata
}
@@ -1291,6 +1350,7 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose, initialMode }) => {
userLabels={voiceLabels}
members={voiceMembers}
userProfile={userProfile}
defaultNotificationTemplates={voiceDefaultNotificationTemplates}
onStateChange={setVoiceState}
/>
)}

View File

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

View File

@@ -6,6 +6,7 @@ import {
GraphicEq,
Lock,
Mic,
NotificationsNone,
Person,
Repeat,
Sell,
@@ -16,9 +17,11 @@ import { Box, Button, Chip, IconButton, Input, Typography } from '@mui/joy'
import moment from 'moment'
import { useEffect, useMemo, useRef, useState } from 'react'
import { TASK_COLOR } from '../../../utils/Colors'
import { isPlusAccount } from '../../../utils/Helpers'
import AssigneePickerField from '../AssigneePickerField'
import DueDatePickerField from '../DueDatePickerField'
import LabelsPickerField from '../LabelsPickerField'
import NotificationPickerField from '../NotificationPickerField'
import PriorityPickerField from '../PriorityPickerField'
import RepeatPickerField from '../RepeatPickerField'
import { parseVoiceTask } from './parseVoiceTask'
@@ -102,7 +105,11 @@ const describeFrequency = f => {
return names[f.frequencyType] || 'Repeats'
}
const buildChips = (effective, frequencyLabel, { members, currentUserId }) => {
const buildChips = (
effective,
frequencyLabel,
{ members, currentUserId, canRemind },
) => {
const chips = []
if (effective.dueDate) {
chips.push({
@@ -128,6 +135,21 @@ const buildChips = (effective, frequencyLabel, { members, currentUserId }) => {
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) {
chips.push({
key: 'points',
@@ -184,9 +206,17 @@ const TaskPreviewCard = ({
[segment.text, parseCtx],
)
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(
() => ({ ...parsed, ...overrides }),
[parsed, overrides],
() => ({
...parsed,
notificationMetadata: {
templates: parseCtx.defaultNotificationTemplates || [],
},
...overrides,
}),
[parsed, overrides, parseCtx.defaultNotificationTemplates],
)
const frequencyLabel =
@@ -329,6 +359,7 @@ const TaskPreviewCard = ({
{expanded && (
<Box
sx={{
mt: 1,
display: 'flex',
flexDirection: 'row',
gap: 1,
@@ -392,6 +423,16 @@ const TaskPreviewCard = ({
onClear={() => onPatch({ labelIds: [] })}
labels={parseCtx.userLabels}
/>
{parseCtx.canRemind && (
<NotificationPickerField
emptyDisplay='icon'
value={effective.notificationMetadata}
onChange={metadata => onPatch({ notificationMetadata: metadata })}
onClear={() =>
onPatch({ notificationMetadata: { templates: [] } })
}
/>
)}
</Box>
)}
</Box>
@@ -412,6 +453,7 @@ const VoicePanel = ({
userLabels = [],
members = [],
userProfile,
defaultNotificationTemplates = [],
onStateChange,
}) => {
const {
@@ -430,8 +472,14 @@ const VoicePanel = ({
const segmentsScrollRef = useRef(null)
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(

View File

@@ -177,24 +177,33 @@ export const buildChorePayload = (
status: 0,
frequencyType: 'once',
frequencyMetadata: {},
notification: false,
notificationMetadata: {},
subTasks: null,
projectId: projectId === 'default' ? null : projectId,
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) {
chore.frequencyType = parsed.frequency.frequencyType
chore.frequencyMetadata = parsed.frequency.frequencyMetadata
chore.frequency = parsed.frequency.frequency
if (isPlusAccount(userProfile)) {
chore.notification = true
chore.notificationMetadata = { templates: notificationTemplates }
}
}
if (!parsed.frequency && parsed.dueDate) {
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