Files
donetick/src/components/NotificationTemplate.jsx

774 lines
25 KiB
JavaScript

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 (
<Box sx={{ mt: 3, mb: 2 }}>
<Typography level={'body-md'} sx={{ mb: 1 }}>
Notification Timeline
</Typography>
<Box
sx={{
display: 'flex',
flexDirection: 'column',
position: 'relative',
height: 90,
bgcolor: 'background.surface',
borderRadius: 'md',
border: '1px solid',
borderColor: 'neutral.outlinedBorder',
p: 2,
transition: 'height 0.3s ease',
// '&:hover': {
// height: 130,
// },
}}
>
{/* Timeline line */}
<Box
sx={{
position: 'relative',
width: '100%',
height: 3,
bgcolor: 'neutral.outlinedBorder',
mt: 2,
}}
>
{/* Due date marker */}
<Box
sx={{
position: 'absolute',
left: '50%',
height: 16,
width: 3,
bgcolor: 'warning.500',
top: -8,
transform: 'translateX(-50%)',
borderRadius: 'sm',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
}}
>
<Typography
level={'body-xs'}
sx={{
mt: 4,
fontWeight: 'md',
color: 'warning.700',
fontSize: '0.6rem',
}}
>
Due Date
</Typography>
</Box>
{/* Notification markers */}
{sorted.map((n, i) => {
// Calculate position based on actual time duration
const percent = getPositionPercent(n.value, n.unit)
return (
<Box
key={i}
sx={{
position: 'absolute',
left: `${percent}%`,
transform: 'translateX(-50%)',
color:
convertToMinutes(n.value, n.unit) < 0
? TASK_COLOR.SCHEDULED
: convertToMinutes(n.value, n.unit) === 0
? TASK_COLOR.TODAY
: TASK_COLOR.LATE,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
transition: 'all 0.3s ease',
cursor: 'pointer',
opacity: 0.85,
'&:hover': {
opacity: 1,
transform: 'translateX(-50%) scale(1.1)',
zIndex: 10,
},
}}
title={getRelativeLabel(n)}
>
<Badge
badgeContent={
notificationIndexMap[
notifications.findIndex(
original =>
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',
},
}}
>
<NotificationsIcon
fontSize={'small'}
sx={{
height: 18,
width: 18,
}}
/>
</Badge>
</Box>
)
})}
</Box>
</Box>
</Box>
)
}
return (
<Box>
{error && (
<Alert
variant='soft'
color='danger'
sx={{ mb: 2 }}
startDecorator={<InfoIcon />}
>
{error}
</Alert>
)}
{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 (
<Box key={idx} sx={{ position: 'relative' }}>
<Badge
badgeContent={badgeNumber}
size={'sm'}
sx={{
'--Badge-minHeight': '16px',
'--Badge-fontSize': '0.7rem',
'--Badge-paddingX': '5px',
top: 10,
left: 10,
'& .MuiBadge-badge': {
background: colors.bgColor,
color: 'white',
},
}}
/>
<Box
sx={{
mb: 1.5,
p: 2,
borderRadius: 8,
border: '1px solid',
borderColor: 'neutral.outlinedBorder',
background: 'background.surface',
transition: 'all 0.2s ease',
display: 'flex',
alignItems: 'center',
gap: 1.5,
}}
>
<Box
sx={{
minWidth: 0,
flex: '1',
display: { xs: 'none', md: 'flex' }, // Show only on md and up
}}
>
<Typography
level='body-sm'
sx={{
fontWeight: 600,
color: 'text.primary',
fontSize: 14,
}}
>
{getRelativeLabel(n)}
</Typography>
</Box>
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 1,
flexShrink: 0,
}}
>
<Select
value={uiRep.timing}
onChange={(_, value) => handleChange(idx, 'timing', value)}
onBlur={() => handleBlur(idx)}
sx={{ minWidth: 80 }}
size={'sm'}
>
{timingOptions.map(opt => (
<Option
key={opt.value}
value={opt.value}
disabled={opt.value === 'ondue' && hasOnDueElsewhere}
>
{opt.label}
</Option>
))}
</Select>
<Input
type={'number'}
min={0}
value={
draftValues[idx] !== undefined
? draftValues[idx]
: uiRep.displayValue
}
disabled={uiRep.timing === 'ondue'}
onChange={e => {
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'
/>
<Select
value={n.unit}
disabled={uiRep.timing === 'ondue'}
onChange={(_, value) => handleChange(idx, 'unit', value)}
onBlur={() => handleBlur(idx)}
sx={{
minWidth: 70,
opacity: uiRep.timing === 'ondue' ? 0.6 : 1,
}}
size={'sm'}
>
{timeUnits.map(opt => (
<Option key={opt.value} value={opt.value}>
{opt.label}
</Option>
))}
</Select>
<IconButton
onClick={() => removeNotification(idx)}
disabled={notifications.length === 1}
color={'danger'}
size={'sm'}
variant={'soft'}
sx={{
transition: 'all 0.2s ease',
'&:hover': {
transform: 'scale(1.1)',
},
}}
>
<DeleteIcon fontSize={'small'} />
</IconButton>
</Box>
</Box>
</Box>
)
})}
<Box
sx={{
display: 'flex',
gap: 1.5,
mt: 1,
mb: 2,
flexWrap: 'wrap',
}}
>
<Button
onClick={() => addSmartNotification('reminder')}
disabled={notifications.length >= maxNotifications}
startDecorator={<AddIcon />}
size={'sm'}
variant={'outlined'}
sx={{
borderRadius: 6,
fontWeight: 500,
borderColor: `${TASK_COLOR.SCHEDULED}60`,
color: TASK_COLOR.SCHEDULED,
'&:hover': {
borderColor: TASK_COLOR.SCHEDULED,
background: `${TASK_COLOR.SCHEDULED}10`,
},
}}
>
Reminder
</Button>
<Button
onClick={() => addSmartNotification('due')}
disabled={
notifications.length >= maxNotifications ||
notifications.some(n => Number(n.value) === 0)
}
startDecorator={<AddIcon />}
size={'sm'}
variant={'outlined'}
sx={{
borderRadius: 6,
fontWeight: 500,
borderColor: `${NOTIFICATION_TYPE.DUE_DATE}60`,
color: NOTIFICATION_TYPE.DUE_DATE,
'&:hover': {
borderColor: NOTIFICATION_TYPE.DUE_DATE,
background: `${NOTIFICATION_TYPE.DUE_DATE}10`,
},
}}
>
Due Alert
</Button>
<Button
onClick={() => addSmartNotification('followup')}
disabled={notifications.length >= maxNotifications}
startDecorator={<AddIcon />}
size={'sm'}
variant={'outlined'}
sx={{
borderRadius: 6,
fontWeight: 500,
borderColor: `${NOTIFICATION_TYPE.POSTDUE}60`,
color: NOTIFICATION_TYPE.POSTDUE,
'&:hover': {
borderColor: NOTIFICATION_TYPE.POSTDUE,
background: `${NOTIFICATION_TYPE.POSTDUE}10`,
},
}}
>
Follow-up
</Button>
</Box>
{showSaveDefault && (
<Box
sx={{
mt: 1,
display: 'flex',
justifyContent: 'start',
alignItems: 'center',
gap: 1,
}}
>
<Button
variant='outlined'
size='sm'
color='neutral'
startDecorator={<Save />}
sx={{
borderRadius: 6,
fontWeight: 500,
'&:hover': {
background: 'neutral.softHoverBg',
},
}}
onClick={() => {
localStorage.setItem(
'defaultNotificationTemplate',
JSON.stringify(notifications),
)
setShowSaveDefault(false)
}}
>
Remember for Future Tasks
</Button>
</Box>
)}
{showTimeline && renderTimeline()}
</Box>
)
}
export default NotificationTemplate