diff --git a/README.md b/README.md index b235431..65b9eae 100644 --- a/README.md +++ b/README.md @@ -20,12 +20,9 @@ As an avid for open-source, I was eager to create a solution that could benefit - Recurring Tasks: Schedule tasks to repeat daily, weekly, monthly, or yearly, with flexible customization options. - Progress Tracking: Track the completion status of tasks and view historical data. -## Installation +## Development Environment -1. Clone the repository: -2. Navigate to the project directory: `cd frontend` -3. Download dependency `npm install` -4. Run locally `npm start` +Follow the full instructions here: https://github.com/donetick/donetick?tab=readme-ov-file#development-environment ## Contributing @@ -41,7 +38,7 @@ Contributions are welcome! If you would like to contribute to Donetick, please f Donetick is a work in progress and has been a fantastic learning experience for me as I've honed my React skills,I'm looking for collaborators to help improve and refine the Donetick. Feel free to open PR or suggest changes. -## Plans : +## Plans: My goal is to expand Donetick by offering a hosted infrastructure option. This will make it even easier for users to access and utilize Donetick's features without the need for self-hosting. diff --git a/src/components/NotificationTemplate.jsx b/src/components/NotificationTemplate.jsx index 0556e39..734fd0b 100644 --- a/src/components/NotificationTemplate.jsx +++ b/src/components/NotificationTemplate.jsx @@ -12,7 +12,7 @@ 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' +import { useCallback, useEffect, useState, useRef } from 'react' import { NOTIFICATION_TYPE, TASK_COLOR } from '../utils/Colors' import { TIME_UNITS } from '../utils/DurationUtils' @@ -73,6 +73,9 @@ const NotificationTemplate = ({ [], ) + 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 @@ -114,7 +117,6 @@ const NotificationTemplate = ({ // Sort notifications and update the index mapping useEffect(() => { updateNotificationIndices() - setError(null) }, [updateNotificationIndices]) // Notify parent component of changes including the template name @@ -125,8 +127,8 @@ const NotificationTemplate = ({ }, [notifications, onChange]) // Validates if a notification configuration already exists - const isDuplicate = (notification, currentIdx = -1) => { - return notifications.some((n, idx) => { + const isDuplicate = (notification, currentIdx = -1, list = notifications) => { + return list.some((n, idx) => { if (idx === currentIdx) return false return ( @@ -136,6 +138,26 @@ const NotificationTemplate = ({ }) } + 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) @@ -149,6 +171,15 @@ const NotificationTemplate = ({ // 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)) @@ -168,71 +199,41 @@ const NotificationTemplate = ({ unit: updatedUIRep.unit, } - // Check if another notification is already "On Due" (value = 0) - if (newInternalValue === 0) { - const existingOnDue = notifications.findIndex( - (n, i) => i !== idx && Number(n.value) === 0, - ) + const updated = notifications.map((n, i) => + i === idx ? updatedNotification : n, + ) + setNotifications(updated) + notificationsRef.current = updated + setError(null) + } - if (existingOnDue !== -1) { - setError( - 'Only one notification can be set to "On Due". Please choose a different timing.', - ) - return - } - } + const handleBlur = idx => { + const currentList = notificationsRef.current + const currentNotification = currentList[idx] - if (isDuplicate(updatedNotification, idx)) { + if (!currentNotification) return + + if (isDuplicate(currentNotification, idx, currentList)) { 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 addSmartNotification = type => { if (notifications.length >= maxNotifications) return setShowSaveDefault(true) let newNotification - let suggestions = [] - - switch (type) { - case 'reminder': - // Suggest common reminder times that don't exist - suggestions = [ - { value: -1, unit: 'd' }, // 1 day before - { value: -3, unit: 'h' }, // 3 hours before - { value: -30, unit: 'm' }, // 3 days before - ] - break - - case 'due': - if (notifications.some(n => Number(n.value) === 0)) { - setError('Only one "Due Alert" notification is allowed.') - return - } - newNotification = { value: 0, unit: 'm' } - break - - case 'followup': - suggestions = [ - { value: 1, unit: 'd' }, // 1 day after - { value: 3, unit: 'd' }, // 3 days after - { value: 7, unit: 'd' }, // 1 week after - ] - break - } - - // For reminder/followup, find first non-duplicate suggestion - if (suggestions.length > 0) { - newNotification = suggestions.find(suggestion => !isDuplicate(suggestion)) + 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 @@ -243,15 +244,25 @@ const NotificationTemplate = ({ 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) => { @@ -459,6 +470,11 @@ const NotificationTemplate = ({ 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 { @@ -487,7 +503,7 @@ const NotificationTemplate = ({ const colors = getNotificationColors(n.value) return ( - <> + handleChange(idx, 'timing', value)} + onBlur={() => handleBlur(idx)} sx={{ minWidth: 80 }} size={'sm'} > {timingOptions.map(opt => ( - ))} @@ -560,11 +579,42 @@ const NotificationTemplate = ({ - handleChange(idx, 'displayValue', e.target.value) + 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, @@ -576,6 +626,7 @@ const NotificationTemplate = ({ 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, @@ -605,7 +656,7 @@ const NotificationTemplate = ({ - + ) })} sameElse: `MMM D ${timeFormat}`, } - // if seconds and minutes set to 59, treat as no time (date only) - if (dueDate.seconds() === 59 && dueDate.minutes() === 59) { + + // if time is 23:59:59, treat as end-of-day (date only, no specific time) + if (dueDate.hours() === 23 && dueDate.minutes() === 59 && dueDate.seconds() === 59) { if (diff < 0) { // For overdue dates, show calendar format for recent dates const absDiff = Math.abs(diff) diff --git a/src/utils/Chores.jsx b/src/utils/Chores.jsx index 6813603..cde9fe7 100644 --- a/src/utils/Chores.jsx +++ b/src/utils/Chores.jsx @@ -16,6 +16,8 @@ export const ChoreHistoryStatus = Object.freeze({ SKIPPED: 2, PENDING_APPROVAL: 3, REJECTED: 4, + MISSED: 5, + RESCHEDULED: 6, }) export const ChoreStatus = Object.freeze({ INACTIVE: 0, @@ -324,7 +326,7 @@ export const notInCompletionWindow = chore => { chore.completionWindow && chore.completionWindow > -1 && chore.nextDueDate && - moment() < moment(chore.nextDueDate).add(-chore.completionWindow, 'seconds') + moment() < moment(chore.nextDueDate).add(-chore.completionWindow, 'hours') ) } export const ChoreFilters = userId => ({ @@ -332,6 +334,9 @@ export const ChoreFilters = userId => ({ assigned_to_me: chore => { return chore.assignedTo && chore.assignedTo === userId }, + available_for_me: chore => { + return chore.assignedTo === null || chore.assignedTo === userId + }, assigned_to_others: chore => { return chore.assignedTo && chore.assignedTo !== userId }, diff --git a/src/views/Authorization/LoginView.jsx b/src/views/Authorization/LoginView.jsx index f0a1467..108ff14 100644 --- a/src/views/Authorization/LoginView.jsx +++ b/src/views/Authorization/LoginView.jsx @@ -771,17 +771,19 @@ const LoginView = () => { )} - + {!resource?.is_user_creation_disabled && ( + + )} { if (dueDateOnly) { const combinedDateTime = moment(`${dueDateOnly}T${defaultTime}`).format( - 'YYYY-MM-DDTHH:mm:00', + 'YYYY-MM-DDTHH:mm:59', ) setDueDate(combinedDateTime) @@ -309,7 +314,7 @@ const ChoreEdit = () => { if (dueDateOnly) { const endOfDay = moment(dueDateOnly) .endOf('day') - .format('YYYY-MM-DDTHH:mm:00') + .format('YYYY-MM-DDTHH:mm:ss') setDueDate(endOfDay) } } @@ -558,7 +563,7 @@ const ChoreEdit = () => { const today = moment(new Date()).format('YYYY-MM-DD') setDueDateOnly(today) // Default to end of day - setDueDate(moment(today).endOf('day').format('YYYY-MM-DDTHH:mm:00')) + setDueDate(moment(today).endOf('day').format('YYYY-MM-DDTHH:mm:59')) setUseCustomTime(false) setDueTime(null) } @@ -1107,7 +1112,7 @@ const ChoreEdit = () => { const today = moment(new Date()).format('YYYY-MM-DD') setDueDateOnly(today) setDueDate( - moment(today).endOf('day').format('YYYY-MM-DDTHH:mm:00'), + moment(today).endOf('day').format('YYYY-MM-DDTHH:mm:59'), ) setUseCustomTime(false) setDueTime(null) @@ -1189,7 +1194,7 @@ const ChoreEdit = () => { checked={completionWindow !== -1} onChange={e => { if (e.target.checked) { - setCompletionWindow(3600) // default 1 hour in seconds + setCompletionWindow(1) // default 1 hour in seconds } else { setCompletionWindow(-1) } @@ -1203,29 +1208,38 @@ const ChoreEdit = () => { {completionWindow !== -1 && ( - - - before due date - + + + Hours: + { + setCompletionWindow(parseInt(e.target.value)) + }} + /> + + )} {/* Expires After (Deadline) */} - + {/* { if (e.target.checked) { setDeadlineOffset(86400) // default 1 day in seconds @@ -1237,9 +1251,11 @@ const ChoreEdit = () => { label='Set a deadline' /> - Task will be considered expired after the due date + {isRolling && !['once', 'no_repeat'].includes(frequencyType) + ? 'Deadline is not available when scheduling from completion date' + : 'Task will be considered expired after the due date'} - + */} {deadlineOffset !== -1 && ( { setIsRolling(true)} + onClick={() => { + setIsRolling(true) + setDeadlineOffset(-1) + }} label='Reschedule from completion date' /> @@ -1624,39 +1643,38 @@ const ChoreEdit = () => { }} > {choreId > 0 && ( - <> - {isActive ? ( - - ) : ( - - )} - - + + + + + + + + Delete + + + )} )} - {!hasDeadline && dueDate && ( + {/* {!hasDeadline && dueDate && ( - )} + )} */} {hasDescription && ( @@ -871,11 +948,30 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => { Due Date setDueDate(e.target.value)} - sx={{ width: '100%', fontSize: '16px' }} + type='date' + value={dueDateOnly || ''} + onChange={handleDueDateChange} /> + handleUseCustomTimeChange(e.target.checked)} + label='Set a specific time' + sx={{ mt: 1 }} + /> + + {useCustomTime + ? 'Task will be due at the specified time' + : 'Task will be due at the end of the day (11:59 PM)'} + + {useCustomTime && ( + + )} )} @@ -978,7 +1074,7 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => { )} */} - {hasDeadline && dueDate && ( + {/* {hasDeadline && dueDate && ( { after due date - )} + )} */} {hasNotifications && dueDate && ( { if ( metadata.notifications !== notificationMetadata.templates ) { - const newNotificaitonMetadata = { + const newNotificationMetadata = { ...notificationMetadata, templates: metadata.notifications, } - setNotificationMetadata(newNotificaitonMetadata) + setNotificationMetadata(newNotificationMetadata) } }} value={notificationMetadata} diff --git a/src/views/components/CustomParsers.js b/src/views/components/CustomParsers.js index 2983ba5..0a45e2d 100644 --- a/src/views/components/CustomParsers.js +++ b/src/views/components/CustomParsers.js @@ -722,8 +722,16 @@ export const parseDueDate = (inputSentence, chrono) => { .replace(/\s+/g, ' ') // Replace multiple spaces with single space .trim() + // If no specific time was mentioned, set to end of day (23:59:59) + // to indicate the date has no specific time tied to it (same convention as ChoreEdit) + let resultDate = dueDateMatch.start.date() + if (!dueDateMatch.start.isCertain('hour')) { + resultDate = new Date(resultDate) + resultDate.setHours(23, 59, 59, 0) + } + return { - result: dueDateMatch.start.date(), + result: resultDate, highlight: [ { text: fullHighlightText, diff --git a/src/views/components/NavBar.jsx b/src/views/components/NavBar.jsx index ec5df1e..98b2483 100644 --- a/src/views/components/NavBar.jsx +++ b/src/views/components/NavBar.jsx @@ -155,6 +155,7 @@ const NavBar = () => { '/login', '/auth/oauth2', '/forgot-password', + '/password/update', '/login/settings', '/welcome', ].includes(location.pathname) diff --git a/src/views/components/RichTextEditor.css b/src/views/components/RichTextEditor.css index 5c5cf95..20147ea 100644 --- a/src/views/components/RichTextEditor.css +++ b/src/views/components/RichTextEditor.css @@ -167,6 +167,13 @@ line-height: 1.5; } +/* Prevent iOS Safari from auto-zooming on focus (triggered when font-size < 16px) */ +@supports (-webkit-touch-callout: none) { + .quill-root .ql-editor { + font-size: 16px; + } +} + /* Custom focus styles */ .quill-root:focus-within .ql-toolbar.ql-snow { border-color: var(--joy-palette-primary-outlinedBorder, #1976d2); diff --git a/src/views/components/SmartTaskTitleInput.jsx b/src/views/components/SmartTaskTitleInput.jsx index 4d87016..8432c44 100644 --- a/src/views/components/SmartTaskTitleInput.jsx +++ b/src/views/components/SmartTaskTitleInput.jsx @@ -213,7 +213,7 @@ const SmartTaskTitleInput = ({ fontSize: 'inherit', lineHeight: 'inherit', backgroundColor: 'transparent', - color: mode === 'dark' ? '#cbd5e1' : '#1a202c', + color: 'transparent', caretColor: mode === 'dark' ? '#fff' : '#000', }} />