From 187b76e04cf943e00cc4ab295f0ca2d8a130367c Mon Sep 17 00:00:00 2001 From: Mo Tarbin Date: Sun, 8 Jun 2025 02:05:59 -0400 Subject: [PATCH] feat: Integrate NotificationTemplate component into ChoreEdit and AddTaskModal for enhanced notification management --- src/components/NotificationTemplate.jsx | 632 ++++++++++++++++++++++++ src/views/ChoreEdit/ChoreEdit.jsx | 79 +-- src/views/components/AddTaskModal.jsx | 115 +++-- 3 files changed, 731 insertions(+), 95 deletions(-) create mode 100644 src/components/NotificationTemplate.jsx diff --git a/src/components/NotificationTemplate.jsx b/src/components/NotificationTemplate.jsx new file mode 100644 index 0000000..9970ea9 --- /dev/null +++ b/src/components/NotificationTemplate.jsx @@ -0,0 +1,632 @@ +import AddIcon from '@mui/icons-material/Add' +import DeleteIcon from '@mui/icons-material/Delete' +import InfoIcon from '@mui/icons-material/Info' +import NotificationsIcon from '@mui/icons-material/Notifications' +import Alert from '@mui/joy/Alert' +import Badge from '@mui/joy/Badge' +import Box from '@mui/joy/Box' +import Button from '@mui/joy/Button' +import IconButton from '@mui/joy/IconButton' +import Input from '@mui/joy/Input' +import Option from '@mui/joy/Option' +import Select from '@mui/joy/Select' +import Typography from '@mui/joy/Typography' +import { useCallback, useEffect, useState } from 'react' + +const timeUnits = [ + { label: 'Minutes', value: 'minutes' }, + { label: 'Hours', value: 'hours' }, + { label: 'Days', value: 'days' }, +] + +const beforeAfterOptions = [ + { label: 'Before Due', value: 'before' }, + { label: 'On Due', value: 'ondue' }, + { label: 'After Due', value: 'after' }, +] + +function getRelativeLabel(notification) { + const { amount, unit, when } = notification + + // For "On Due" notification + if (when === 'ondue') { + return 'On due date' + } + + // For before/after notifications + return `${amount} ${unit} ${when === 'before' ? 'before' : 'after'} due` +} + +const NotificationTemplate = ({ maxNotifications = 5, onChange, value }) => { + const [templateName, setTemplateName] = useState( + value?.name || 'New Notification Template', + ) + const [notifications, setNotifications] = useState([ + value?.notifications || { + amount: 0, + when: 'ondue', + }, + ]) + const [error, setError] = useState(null) + + // Create a map of notification indices for timeline display + const [notificationIndexMap, setNotificationIndexMap] = useState({}) + + // Use useCallback to memoize the function + const updateNotificationIndices = useCallback(() => { + // Sort notifications for consistent ordering + const sorted = [...notifications].sort((a, b) => { + // Always ensure correct ordering: Before Due -> On Due -> After Due + if (a.when !== b.when) { + // Before Due always comes first + if (a.when === 'before') return -1 + if (b.when === 'before') return 1 + + // On Due comes before After Due + if (a.when === 'ondue') return -1 + if (b.when === 'ondue') return 1 + + // Default case (should not be reached with our options) + return 0 + } + + // Convert everything to minutes for consistent comparison + const getMinutes = notif => { + const { amount, unit } = notif + let minutes = amount + if (unit === 'hours') minutes *= 60 + if (unit === 'days') minutes *= 24 * 60 + return minutes + } + + // For Before Due: sort in descending order (furthest from due first) + // For After Due: sort in ascending order (closest to due first) + const aMinutes = getMinutes(a) + const bMinutes = getMinutes(b) + return a.when === 'before' ? bMinutes - aMinutes : aMinutes - bMinutes + }) + + // Create index mapping + const indexMap = {} + sorted.forEach((item, index) => { + const originalIdx = notifications.findIndex( + n => + n.amount === item.amount && + n.unit === item.unit && + n.when === item.when, + ) + indexMap[originalIdx] = index + 1 + }) + + setNotificationIndexMap(indexMap) + }, [notifications]) + + // Sort notifications and update the index mapping + useEffect(() => { + updateNotificationIndices() + // Clear any errors when notifications change + setError(null) + }, [updateNotificationIndices]) + + // Notify parent component of changes including the template name + useEffect(() => { + if (onChange) { + onChange({ name: templateName, notifications }) + } + }, [templateName, notifications, onChange]) + + // Validates if a notification configuration already exists + const isDuplicate = (notification, currentIdx = -1) => { + return notifications.some((n, idx) => { + // Skip comparing with itself when editing + if (idx === currentIdx) return false + + return ( + n.amount === notification.amount && + n.unit === notification.unit && + n.when === notification.when + ) + }) + } + + const handleChange = (idx, field, value) => { + let updatedNotification = { + ...notifications[idx], + [field]: value, + } + + // Special handling for "On Due" option + if (field === 'when' && value === 'ondue') { + // Set default values for On Due (not applicable) + updatedNotification = { + ...updatedNotification, + amount: 0, + unit: 'minutes', // default unit, not displayed to user + } + + // Check if another notification is already "On Due" + const existingOnDue = notifications.findIndex( + (n, i) => i !== idx && n.when === 'ondue', + ) + + if (existingOnDue !== -1) { + setError( + 'Only one notification can be set to "On Due". Please choose a different timing.', + ) + return + } + } + + // Check for duplicates when changing a notification + if (isDuplicate(updatedNotification, idx)) { + setError( + 'This notification setting already exists. Please use a different timing.', + ) + return + } + + const updated = notifications.map((n, i) => + i === idx ? updatedNotification : n, + ) + setNotifications(updated) + setError(null) + } + + const handleNameChange = e => { + setTemplateName(e.target.value) + } + const addSmartNotification = type => { + if (notifications.length >= maxNotifications) return + + let newNotification + let suggestions = [] + + switch (type) { + case 'reminder': + // Suggest common reminder times that don't exist + suggestions = [ + { amount: 1, unit: 'hours', when: 'before' }, + { amount: 1, unit: 'days', when: 'before' }, + { amount: 30, unit: 'minutes', when: 'before' }, + { amount: 2, unit: 'hours', when: 'before' }, + { amount: 3, unit: 'days', when: 'before' }, + ] + break + + case 'due': + if (notifications.some(n => n.when === 'ondue')) { + setError('Only one "Due Alert" notification is allowed.') + return + } + newNotification = { amount: 0, unit: 'minutes', when: 'ondue' } + break + + case 'followup': + suggestions = [ + { amount: 1, unit: 'hours', when: 'after' }, + { amount: 1, unit: 'days', when: 'after' }, + { amount: 3, unit: 'days', when: 'after' }, + { amount: 1, unit: 'weeks', when: 'after' }, + ] + break + } + + // For reminder/followup, find first non-duplicate suggestion + if (suggestions.length > 0) { + newNotification = suggestions.find(suggestion => !isDuplicate(suggestion)) + + if (!newNotification) { + setError(`All common ${type} times are already configured.`) + return + } + } + + // Insert the new notification in the correct chronological position + const updatedNotifications = [...notifications, newNotification].sort( + (a, b) => { + // Convert everything to minutes for consistent comparison + const getMinutes = notif => { + const { amount, unit, when } = notif + // On Due is exactly at due date (0 minutes) + if (when === 'ondue') return 0 + + let minutes = amount + if (unit === 'hours') minutes *= 60 + if (unit === 'days') minutes *= 24 * 60 + return when === 'before' ? -minutes : minutes + } + + return getMinutes(a) - getMinutes(b) + }, + ) + + setNotifications(updatedNotifications) + setError(null) + } + + const removeNotification = idx => { + const updated = notifications.filter((_, i) => i !== idx) + setNotifications(updated) + onChange && onChange(updated) + } + // Visualization: improved timeline with better scaling + const renderTimeline = () => { + // Sort notifications chronologically + const sorted = [...notifications].sort((a, b) => { + // Convert everything to minutes for consistent comparison + const getMinutes = notif => { + const { amount, unit, when } = notif + // On Due is exactly at due date (0 minutes) + if (when === 'ondue') return 0 + + let minutes = amount + if (unit === 'hours') minutes *= 60 + if (unit === 'days') minutes *= 24 * 60 + return when === 'before' ? -minutes : minutes + } + + return getMinutes(a) - getMinutes(b) + }) + + // Create a map to track sorted indices for original notifications + const notificationIndexMap = {} + sorted.forEach((item, index) => { + // Find the original index of this item in notifications array + const originalIdx = notifications.findIndex( + n => + n.amount === item.amount && + n.unit === item.unit && + n.when === item.when, + ) + notificationIndexMap[originalIdx] = index + 1 + }) // Get min and max notification times for dynamic scaling + const minutesValues = sorted.map(n => { + // On Due is exactly at due date (0 minutes) + if (n.when === 'ondue') return 0 + + let minutes = n.amount + if (n.unit === 'hours') minutes *= 60 + if (n.unit === 'days') minutes *= 24 * 60 + return n.when === 'before' ? -minutes : minutes + }) + + // Find min (before) and max (after) notification times + const minBefore = Math.min(0, ...minutesValues) // Default to 0 if no "before" notifications + const maxAfter = Math.max(0, ...minutesValues) // Default to 0 if no "after" notifications + + // Dynamic scaling based on notification range + const getPositionPercent = minutes => { + // Due date is always at center (50%) + if (minutes === 0) return 50 + + // For notifications before due date + if (minutes < 0) { + if (minBefore === 0) return 30 // Default position if no before notifications + // Scale between 10% (furthest left) and 45% (closest to due) + return 45 - (Math.abs(minutes) / Math.abs(minBefore)) * 35 + } + + // For notifications after due date + if (maxAfter === 0) return 70 // Default position if no after notifications + // Scale between 55% (closest to due) and 90% (furthest right) + return 55 + (minutes / maxAfter) * 35 + } + + return ( + + + Notification Timeline + + + {/* Timeline line */} + + {/* Due date marker */} + + + Due Date + + + + {/* Notification markers */} + {sorted.map((n, i) => { + // Convert to minutes for consistent scale + let minutes = 0 + if (n.when !== 'ondue') { + minutes = n.amount + if (n.unit === 'hours') minutes *= 60 + if (n.unit === 'days') minutes *= 24 * 60 + if (n.when === 'before') minutes = -minutes + } + // On Due notifications are always at the due date (0 minutes) + + // Calculate position based on dynamic scaling + const percent = getPositionPercent(minutes) + + return ( + + + + + + ) + })} + + + + ) + } + + return ( + + + Schedule Name + + + {/* Template Name Field */} + + + Template Name + + + + + {error && ( + } + > + {error} + + )} + + {notifications.map((n, idx) => { + // Get ordered badge number from timeline sorting + const badgeNumber = notificationIndexMap[idx] + + return ( + + + + + {/* Empty box to attach badge to */} + + + + {/* Show disabled fields for "On Due" option for visual consistency */} + + handleChange(idx, 'amount', Math.max(1, Number(e.target.value))) + } + sx={{ + width: 70, + mr: 1, + opacity: n.when === 'ondue' ? 0.6 : 1, + ...(n.when === 'ondue' && { + '& input': { + textAlign: 'center', + }, + }), + }} + size={'sm'} + placeholder={n.when === 'ondue' ? '—' : ''} + /> + + removeNotification(idx)} + disabled={notifications.length === 1} + color={'danger'} + size={'sm'} + sx={{ mr: 1 }} + variant={'soft'} + > + + + + ) + })} + + + + + + {renderTimeline()} + + ) +} + +export default NotificationTemplate diff --git a/src/views/ChoreEdit/ChoreEdit.jsx b/src/views/ChoreEdit/ChoreEdit.jsx index 27dd9a7..e2c8bc0 100644 --- a/src/views/ChoreEdit/ChoreEdit.jsx +++ b/src/views/ChoreEdit/ChoreEdit.jsx @@ -24,14 +24,15 @@ import { Typography, } from '@mui/joy' import moment from 'moment' -import { useContext, useEffect, useState } from 'react' +import { useEffect, useState } from 'react' import { useNavigate, useParams, useSearchParams } from 'react-router-dom' -import { UserContext } from '../../contexts/UserContext' +import NotificationTemplate from '../../components/NotificationTemplate.jsx' import { useChore, useCreateChore, useUpdateChore, } from '../../queries/ChoreQueries.jsx' +import { useUserProfile } from '../../queries/UserQueries.jsx' import { getTextColorFromBackgroundColor } from '../../utils/Colors.jsx' import { DeleteChore, @@ -61,7 +62,8 @@ const REPEAT_ON_TYPE = ['interval', 'days_of_the_week', 'day_of_the_month'] const NO_DUE_DATE_REQUIRED_TYPE = ['no_repeat', 'once'] const NO_DUE_DATE_ALLOWED_TYPE = ['trigger'] const ChoreEdit = () => { - const { userProfile, setUserProfile } = useContext(UserContext) + const { data: userProfile } = useUserProfile() + const [chore, setChore] = useState([]) const [choresHistory, setChoresHistory] = useState([]) const [userHistory, setUserHistory] = useState({}) @@ -708,6 +710,10 @@ const ChoreEdit = () => { { setIsNotificable(e.target.checked) + // if unchecking, reset notification metadata: + if (!e.target.checked) { + setNotificationMetadata({}) + } }} defaultChecked={isNotificable} checked={isNotificable} @@ -726,7 +732,6 @@ const ChoreEdit = () => { {isNotificable && ( { }} > - - What things should trigger the notification? - - {[ - { - title: 'Due Date/Time', - description: 'A simple reminder that a task is due', - id: 'dueDate', - }, - // { - // title: 'Upon Completion', - // description: 'A notification when a task is completed', - // id: 'completion', - // }, - { - title: 'Predued', - description: 'before a task is due in few hours', - id: 'predue', - }, - // { - // title: 'Overdue', - // description: 'A notification when a task is overdue', - // id: 'overdue', - // }, - { - title: 'Nagging', - description: 'Daily reminders until the task is completed', - id: 'nagging', - }, - ].map(item => ( - - { - setNotificationMetadata({ - ...notificationMetadata, - [item.id]: !notificationMetadata[item.id], - }) - }} - checked={ - notificationMetadata ? notificationMetadata[item.id] : false + Notification Schedule: + + { + const newNotificaitonMetadata = { + ...notificationMetadata, + templates: metadata.notifications, } - label={item.title} - key={item.title} - /> - {item.description} - - ))} - - - What things should trigger the notification? - + setNotificationMetadata(newNotificaitonMetadata) + }} + value={notificationMetadata} + /> + + Choose Who to Notify: { onChange={(event, newValue) => { setLabelsV2(userLabels.filter(l => newValue.indexOf(l.name) > -1)) }} - value={labelsV2.map(l => l.name)} + value={labelsV2?.map(l => l.name)} renderValue={selected => ( {labelsV2.map(selectedOption => { diff --git a/src/views/components/AddTaskModal.jsx b/src/views/components/AddTaskModal.jsx index 55d6f38..93a82fc 100644 --- a/src/views/components/AddTaskModal.jsx +++ b/src/views/components/AddTaskModal.jsx @@ -1,4 +1,4 @@ -import { Add } from '@mui/icons-material' +import { Add, EditNotifications } from '@mui/icons-material' import { Box, Button, @@ -14,15 +14,20 @@ import { import { FormControl } from '@mui/material' import * as chrono from 'chrono-node' import moment from 'moment' -import { useCallback, useContext, useEffect, useRef, useState } from 'react' -import { UserContext } from '../../contexts/UserContext' +import { useCallback, useEffect, useRef, useState } from 'react' import { useCreateChore } from '../../queries/ChoreQueries' -import { useCircleMembers } from '../../queries/UserQueries' +import { useCircleMembers, useUserProfile } from '../../queries/UserQueries' import { isPlusAccount } from '../../utils/Helpers' import { useLabels } from '../Labels/LabelQueries' -import { parseLabels, parsePriority, parseRepeatV2 } from './CustomParsers' +import { + parseDueDate, + parseLabels, + parsePriority, + parseRepeatV2, +} from './CustomParsers' import SmartTaskTitleInput from './SmartTaskTitleInput' +import NotificationTemplate from '../../components/NotificationTemplate' import LearnMoreButton from './LearnMore' import RichTextEditor from './RichTextEditor' import SubTasks from './SubTask' @@ -33,7 +38,8 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => { useCircleMembers() const createChoreMutation = useCreateChore() - const { userProfile } = useContext(UserContext) + const { data: userProfile } = useUserProfile() + const [taskText, setTaskText] = useState('') const [taskTitle, setTaskTitle] = useState('') const [renderedParts, setRenderedParts] = useState([]) @@ -46,11 +52,14 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => { const [assignees, setAssignees] = useState([]) const [labelsV2, setLabelsV2] = useState([]) const [frequency, setFrequency] = useState(null) + const [notificationMetadata, setNotificationMetadata] = useState({ + templates: [], + }) const [frequencyHumanReadable, setFrequencyHumanReadable] = useState(null) const [subTasks, setSubTasks] = useState(null) const [hasDescription, setHasDescription] = useState(false) const [hasSubTasks, setHasSubTasks] = useState(false) - + const [hasNotifications, setHasNotifications] = useState(false) useEffect(() => { if (isModalOpen && textareaRef.current) { textareaRef.current.focus() @@ -233,25 +242,22 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => { // }, // ]) // } - const parsedDueDate = chrono.parse(sentence, new Date(), { - forwardDate: true, - }) - if (parsedDueDate[0]?.index > -1) { - setDueDate( - moment(parsedDueDate[0].start.date()).format('YYYY-MM-DDTHH:mm:ss'), - ) - cleanedSentence = cleanedSentence.replace(parsedDueDate[0].text, '') + // Parse due date + const dueDateParsed = parseDueDate(sentence, chrono) + let dueDateHighlight = null + if (dueDateParsed.result) { + setDueDate(moment(dueDateParsed.result).format('YYYY-MM-DDTHH:mm:ss')) + cleanedSentence = dueDateParsed.cleanedSentence + dueDateHighlight = dueDateParsed.highlight[0] } if (repeat.result) { // if repeat has result the cleaned sentence will remove the date related info which mean // we need to reparse the date again to get the correct due date: - const parsedDueDate = chrono.parse(sentence, new Date(), { - forwardDate: true, - }) - if (parsedDueDate[0]?.index > -1) { + const dueDateParsedAgain = parseDueDate(sentence, chrono) + if (dueDateParsedAgain.result) { setDueDate( - moment(parsedDueDate[0].start.date()).format('YYYY-MM-DDTHH:mm:ss'), + moment(dueDateParsedAgain.result).format('YYYY-MM-DDTHH:mm:ss'), ) } } @@ -263,19 +269,13 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => { repeat.highlight, priority.highlight, labels.highlight, - parsedDueDate && parsedDueDate[0] - ? { - start: parsedDueDate[0].index, - end: parsedDueDate[0].index + parsedDueDate[0].text.length, - text: parsedDueDate[0].text, - } - : null, + dueDateHighlight, ) setRenderedParts(parts) setTaskTitle(plainText) }, - [circleMembers, userLabels, userProfile, renderHighlightedSentence], + [userLabels, renderHighlightedSentence], ) useEffect(() => { @@ -313,9 +313,11 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => { } const handleSubmit = () => { - createChore() - handleCloseModal() - setTaskText('') + console.log('Submitting task:', isPlusAccount(userProfile)) + + // createChore() + // handleCloseModal() + // setTaskText('') } const createChore = () => { @@ -327,8 +329,7 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => { assignedTo: assignees.length > 0 ? assignees[0].userId : userProfile.id, assignStrategy: 'random', isRolling: false, - notification: false, - description: description || null, + labelsV2: labelsV2, priority: priority ? Number(priority) : 0, status: 0, @@ -342,9 +343,9 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => { chore.frequencyType = frequency.frequencyType chore.frequencyMetadata = frequency.frequencyMetadata chore.frequency = frequency.frequency - if (isPlusAccount()) { + if (isPlusAccount(userProfile)) { chore.notification = true - chore.notificationMetadata = { dueDate: true } + chore.notificationMetadata = notificationMetadata } } if (!frequency && dueDate) { @@ -518,6 +519,23 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => { Due Date )} + {!hasNotifications && dueDate && ( + + )} {hasDescription && ( @@ -606,10 +624,29 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => { )} */} - - Frequency - - + {hasNotifications && dueDate && ( + + Notification Schedule + + { + const newNotificaitonMetadata = { + ...notificationMetadata, + templates: metadata.notifications, + } + setNotificationMetadata(newNotificaitonMetadata) + }} + value={notificationMetadata} + showTimeline={false} + /> + + + )}