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 && (
+ }
+ variant='plain'
+ size='sm'
+ onClick={() => {
+ setHasNotifications(true)
+ setFrequencyHumanReadable('Once')
+ setFrequency(null)
+ setDueDate(
+ moment().add(1, 'day').format('YYYY-MM-DDTHH:00:00'),
+ )
+ }}
+ >
+ Edit Notifications
+
+ )}
{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}
+ />
+
+
+ )}