import { Save } from '@mui/icons-material' 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, useRef, useState } from 'react' import { NOTIFICATION_TYPE, TASK_COLOR } from '../utils/Colors' import { TIME_UNITS } from '../utils/DurationUtils' const timeUnits = TIME_UNITS const timingOptions = [ { label: 'Before', value: 'before' }, { label: 'Due', value: 'ondue' }, { label: 'After', value: 'after' }, ] function getRelativeLabel(notification) { const { value, unit } = notification const numericValue = Number(value) if (numericValue === 0) { return 'On due date' } const unitName = unit === 'm' ? 'minutes' : unit === 'h' ? 'hours' : 'days' const absValue = Math.abs(numericValue) return `${absValue} ${unitName} ${numericValue < 0 ? 'before' : 'after'} due` } // Helper functions to convert between internal value and UI representation function getUIRepresentation(notification) { const numericValue = Number(notification.value) if (numericValue === 0) { return { timing: 'ondue', displayValue: 0, unit: notification.unit } } else if (numericValue < 0) { return { timing: 'before', displayValue: Math.abs(numericValue), unit: notification.unit, } } else { return { timing: 'after', displayValue: numericValue, unit: notification.unit, } } } function getInternalValue(timing, displayValue) { if (timing === 'ondue') return 0 if (timing === 'before') return -Math.abs(displayValue) return Math.abs(displayValue) // 'after' } const NotificationTemplate = ({ maxNotifications = 5, onChange, value, showTimeline = true, }) => { const [notifications, setNotifications] = useState( value?.templates || JSON.parse(localStorage.getItem('defaultNotificationTemplate')) || [], ) const notificationsRef = useRef(notifications) const [draftValues, setDraftValues] = useState({}) const [error, setError] = useState(null) const [showSaveDefault, setShowSaveDefault] = useState(false) // Create a map of notification indices for timeline display const [notificationIndexMap, setNotificationIndexMap] = useState({}) const updateNotificationIndices = useCallback(() => { // Convert notifications to minutes for proper chronological sorting const convertToMinutes = (value, unit) => { const numericValue = Number(value) if (numericValue === 0) return 0 let minutes = Math.abs(numericValue) if (unit === 'h') minutes *= 60 if (unit === 'd') minutes *= 24 * 60 return numericValue < 0 ? -minutes : minutes } // Sort notifications for consistent ordering by actual time duration const sorted = [...notifications].sort((a, b) => { const aMinutes = convertToMinutes(a.value, a.unit) const bMinutes = convertToMinutes(b.value, b.unit) return aMinutes - bMinutes }) const indexMap = {} // Map original array indices to their chronological position numbers notifications.forEach((originalNotification, originalIdx) => { const chronologicalPosition = sorted.findIndex( sortedNotification => Number(sortedNotification.value) === Number(originalNotification.value) && sortedNotification.unit === originalNotification.unit, ) indexMap[originalIdx] = chronologicalPosition + 1 }) setNotificationIndexMap(indexMap) }, [notifications]) // Sort notifications and update the index mapping useEffect(() => { updateNotificationIndices() }, [updateNotificationIndices]) // Notify parent component of changes including the template name useEffect(() => { if (onChange) { onChange({ notifications }) } }, [notifications, onChange]) // Validates if a notification configuration already exists const isDuplicate = (notification, currentIdx = -1, list = notifications) => { return list.some((n, idx) => { if (idx === currentIdx) return false return ( Number(n.value) === Number(notification.value) && n.unit === notification.unit ) }) } 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 currentNotification = notifications[idx] const uiRep = getUIRepresentation(currentNotification) let updatedUIRep = { ...uiRep } let updatedNotification = { ...currentNotification } // Update the UI representation based on the field being changed if (field === 'timing') { updatedUIRep.timing = value // Reset display value when switching to "On Due" if (value === 'ondue') { 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') { updatedUIRep.displayValue = Math.max(0, Number(value)) } else if (field === 'unit') { updatedUIRep.unit = value updatedNotification.unit = value } // Convert back to internal representation const newInternalValue = getInternalValue( updatedUIRep.timing, updatedUIRep.displayValue, ) updatedNotification = { ...updatedNotification, value: newInternalValue, unit: updatedUIRep.unit, } const updated = notifications.map((n, i) => i === idx ? updatedNotification : n, ) setNotifications(updated) notificationsRef.current = updated setError(null) } const handleBlur = idx => { const currentList = notificationsRef.current const currentNotification = currentList[idx] if (!currentNotification) return if (isDuplicate(currentNotification, idx, currentList)) { setError( 'This notification setting already exists. Please use a different timing.', ) return } } const addSmartNotification = type => { if (notifications.length >= maxNotifications) return setShowSaveDefault(true) let newNotification 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) { setError(`All common ${type} times are already configured.`) return } } // Add the new notification to the end (don't sort, keep form order) const updatedNotifications = [...notifications, newNotification] setNotifications(updatedNotifications) notificationsRef.current = updatedNotifications setError(null) } const removeNotification = idx => { const updated = notifications.filter((_, i) => i !== idx) setNotifications(updated) notificationsRef.current = updated setDraftValues(prev => { const next = { ...prev } delete next[idx] return next }) onChange && onChange(updated) setShowSaveDefault(true) } const renderTimeline = () => { // Convert notifications to minutes for proper chronological sorting const convertToMinutes = (value, unit) => { const numericValue = Number(value) if (numericValue === 0) return 0 let minutes = Math.abs(numericValue) if (unit === 'h') minutes *= 60 if (unit === 'd') minutes *= 24 * 60 return numericValue < 0 ? -minutes : minutes } // Sort notifications chronologically by actual time (in minutes) const sorted = [...notifications].sort((a, b) => { const aMinutes = convertToMinutes(a.value, a.unit) const bMinutes = convertToMinutes(b.value, b.unit) return aMinutes - bMinutes }) // Get min and max notification times in minutes for dynamic scaling const minutesValues = sorted.map(n => convertToMinutes(n.value, n.unit)) 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 const getPositionPercent = (value, unit) => { const minutes = convertToMinutes(value, unit) // Due date is always at center (50%) if (minutes === 0) return 50 // For notifications before due date (negative values) 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 (positive values) 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) => { // Calculate position based on actual time duration const percent = getPositionPercent(n.value, n.unit) return ( Number(original.value) === Number(n.value) && original.unit === n.unit, ) ] || i + 1 } size={'sm'} variant={'solid'} sx={{ '--Badge-paddingX': '4px', '--Badge-minHeight': '16px', '--Badge-fontSize': '0.65rem', display: 'flex', alignItems: 'center', justifyContent: 'center', '& .MuiBadge-badge': { background: convertToMinutes(n.value, n.unit) < 0 ? NOTIFICATION_TYPE.PREDUE : convertToMinutes(n.value, n.unit) === 0 ? NOTIFICATION_TYPE.DUE_DATE : NOTIFICATION_TYPE.POSTDUE, color: 'white', }, }} > ) })} ) } return ( {error && ( } > {error} )} {notifications .map((n, idx) => ({ notification: n, originalIndex: idx })) .sort((a, b) => { const aBadgeNumber = notificationIndexMap[a.originalIndex] || 0 const bBadgeNumber = notificationIndexMap[b.originalIndex] || 0 return aBadgeNumber - bBadgeNumber }) .map(({ notification: n, originalIndex: idx }) => { // Get ordered badge number from timeline sorting const badgeNumber = notificationIndexMap[idx] 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 => { if (Number(value) < 0) { return { bgColor: NOTIFICATION_TYPE.PREDUE, lightBg: `${NOTIFICATION_TYPE.PREDUE}20`, borderColor: `${NOTIFICATION_TYPE.PREDUE}40`, textColor: NOTIFICATION_TYPE.PREDUE, } } else if (Number(value) === 0) { return { bgColor: NOTIFICATION_TYPE.DUE_DATE, lightBg: `${NOTIFICATION_TYPE.DUE_DATE}20`, borderColor: `${NOTIFICATION_TYPE.DUE_DATE}40`, textColor: NOTIFICATION_TYPE.DUE_DATE, } } else { return { bgColor: NOTIFICATION_TYPE.POSTDUE, lightBg: `${NOTIFICATION_TYPE.POSTDUE}20`, borderColor: `${NOTIFICATION_TYPE.POSTDUE}40`, textColor: NOTIFICATION_TYPE.POSTDUE, } } } const colors = getNotificationColors(n.value) return ( {getRelativeLabel(n)} { 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={{ width: 60, opacity: uiRep.timing === 'ondue' ? 0.6 : 1, }} size={'sm'} placeholder='0' /> removeNotification(idx)} disabled={notifications.length === 1} color={'danger'} size={'sm'} variant={'soft'} sx={{ transition: 'all 0.2s ease', '&:hover': { transform: 'scale(1.1)', }, }} > ) })} {showSaveDefault && ( )} {showTimeline && renderTimeline()} ) } export default NotificationTemplate