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:
@@ -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'}
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user