Files
donetick/src/views/components/DueDatePickerModal.jsx
Mo Tarbin 0bac419c3a feat: enhance localization and user experience across various components
- Added new localization keys for project management, settings, and onboarding views.
- Updated labels and placeholders in project modal, password change modal, and backup/restore modal for better clarity.
- Improved user feedback messages for password mismatch and backup creation failures.
- Enhanced the timer details view with localized strings for active work, sessions, and distribution.
- Refined the due date picker modal with localized quick date and time options.
- Updated photo task modal and scan panel to include localized text for better accessibility.
- Ensured consistent use of translation function for all user-facing strings.
2026-08-18 01:31:16 -04:00

551 lines
17 KiB
JavaScript

import {
Bedtime,
EventNote,
LightMode,
NextWeek,
NightsStay,
Today,
WbSunny,
WbTwilight,
Weekend,
} from '@mui/icons-material'
import {
Box,
Button,
Checkbox,
Input,
List,
ListItem,
Typography,
} from '@mui/joy'
import moment from 'moment'
import { useEffect, useState } from 'react'
import Calendar from 'react-calendar'
import { useTranslation } from 'react-i18next'
import ModalActions from '../../components/common/ModalActions'
import { useLocalization } from '../../contexts/LocalizationContext'
import { useResponsiveModal } from '../../hooks/useResponsiveModal'
// Split a date-ish value (ISO string / Date) into the parts this picker edits.
export const splitDueDate = value => {
if (!value) {
return { dueDateOnly: null, dueTime: null, useCustomTime: false }
}
const m = moment(value)
if (!m.isValid()) {
return { dueDateOnly: null, dueTime: null, useCustomTime: false }
}
const time = m.format('HH:mm')
return {
dueDateOnly: m.format('YYYY-MM-DD'),
dueTime: time,
// Midnight is how a date-only value round-trips, so treat it as "anytime"
useCustomTime: time !== '00:00',
}
}
// Inverse of splitDueDate — returns a Date, or null when there is no due date.
export const combineDueDate = ({ dueDateOnly, dueTime, useCustomTime }) => {
if (!dueDateOnly) return null
const time = useCustomTime && dueTime ? dueTime : '00:00'
return moment(`${dueDateOnly} ${time}`, 'YYYY-MM-DD HH:mm').toDate()
}
export const getQuickScheduleDate = option => {
const now = new Date()
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate())
switch (option) {
case 'today':
return today
case 'tomorrow': {
const tomorrow = new Date(today)
tomorrow.setDate(today.getDate() + 1)
return tomorrow
}
case 'weekend': {
const weekend = new Date(today)
const daysUntilSaturday = (6 - today.getDay() + 7) % 7 || 7
weekend.setDate(today.getDate() + daysUntilSaturday)
return weekend
}
case 'next-week': {
const nextWeek = new Date(today)
const daysUntilMonday = (1 - today.getDay() + 7) % 7 || 7
nextWeek.setDate(today.getDate() + daysUntilMonday)
return nextWeek
}
case 'next-month': {
const nextMonth = new Date(today)
nextMonth.setMonth(today.getMonth() + 1)
return nextMonth
}
default:
return today
}
}
const toDateKey = date => moment(date).format('YYYY-MM-DD')
/**
* The shared due-date picker UI (quick dates, quick times, calendar, custom
* time). Used both by DueDatePickerField and by anything that needs to
* reschedule a task — task cards, swipe actions, action menus.
*/
const DueDatePickerModal = ({
applyLabel,
dueDateOnly,
dueTime,
onApply,
onClose,
onRemove,
open,
title,
useCustomTime,
}) => {
const { t } = useTranslation('chores')
const { ResponsiveModal } = useResponsiveModal()
const { firstDayOfWeek } = useLocalization()
// Local buffered state — only committed on Apply
const [localDueDateOnly, setLocalDueDateOnly] = useState(dueDateOnly)
const [localDueTime, setLocalDueTime] = useState(dueTime)
const [localUseCustomTime, setLocalUseCustomTime] = useState(useCustomTime)
// Sync local state from props whenever the modal opens
useEffect(() => {
if (open) {
setLocalDueDateOnly(dueDateOnly)
setLocalDueTime(dueTime)
setLocalUseCustomTime(useCustomTime)
}
}, [open, dueDateOnly, dueTime, useCustomTime])
const calendarType =
firstDayOfWeek === 1
? 'iso8601'
: firstDayOfWeek === 6
? 'islamic'
: 'gregory'
const pillListSx = {
'--List-gap': '8px',
'--ListItem-radius': '20px',
}
const handleQuickSchedule = option => {
setLocalDueDateOnly(toDateKey(getQuickScheduleDate(option)))
}
const handleQuickTime = timeStr => {
// Tap the active chip again to deselect it
if (localUseCustomTime && localDueTime === timeStr) {
setLocalUseCustomTime(false)
setLocalDueTime(null)
return
}
if (!localDueDateOnly) {
setLocalDueDateOnly(toDateKey(new Date()))
}
setLocalUseCustomTime(true)
setLocalDueTime(timeStr)
}
const handleCalendarChange = selected => {
if (!selected || Array.isArray(selected)) return
setLocalDueDateOnly(moment(selected).format('YYYY-MM-DD'))
}
const handleLocalTimeInputChange = e => {
setLocalUseCustomTime(true)
setLocalDueTime(e.target.value)
}
const handleSave = () => {
onApply?.({
dueDateOnly: localDueDateOnly || null,
dueTime: localUseCustomTime ? localDueTime || null : null,
useCustomTime: Boolean(localUseCustomTime && localDueTime),
})
}
return (
<ResponsiveModal
open={open}
onClose={onClose}
title={title ?? t('duePicker.title')}
fullWidth={false}
footer={
<ModalActions
tertiary={
onRemove && dueDateOnly
? {
label: t('duePicker.remove'),
color: 'danger',
onClick: onRemove,
}
: undefined
}
secondary={{ label: t('common:cancel'), onClick: onClose }}
primary={{
label: applyLabel ?? t('duePicker.apply'),
onClick: handleSave,
}}
/>
}
>
<Box sx={{ fontFamily: 'var(--joy-fontFamily-body)', maxWidth: 360 }}>
{/* Date shortcuts */}
<Typography
level='body-xs'
sx={{
mb: 0.75,
color: 'text.tertiary',
textTransform: 'uppercase',
letterSpacing: '0.05em',
}}
>
{t('duePicker.quickDate')}
</Typography>
<List orientation='horizontal' wrap sx={{ ...pillListSx, mb: 1.5 }}>
{[
{
key: 'today',
label: t('duePicker.today'),
icon: <Today sx={{ fontSize: 14 }} />,
},
{
key: 'tomorrow',
label: t('duePicker.tomorrow'),
icon: <WbSunny sx={{ fontSize: 14 }} />,
},
{
key: 'weekend',
label: t('duePicker.weekend'),
icon: <Weekend sx={{ fontSize: 14 }} />,
},
{
key: 'next-week',
label: t('duePicker.nextWeek'),
icon: <NextWeek sx={{ fontSize: 14 }} />,
},
{
key: 'next-month',
label: t('duePicker.nextMonth'),
icon: <EventNote sx={{ fontSize: 14 }} />,
},
].map(opt => {
const dateStr = toDateKey(getQuickScheduleDate(opt.key))
return (
<ListItem key={opt.key}>
<Checkbox
checked={localDueDateOnly === dateStr}
onClick={() => handleQuickSchedule(opt.key)}
overlay
disableIcon
variant='soft'
label={
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 0.5,
}}
>
{opt.icon}
{opt.label}
</Box>
}
/>
</ListItem>
)
})}
</List>
{/* Time shortcuts */}
<Typography
level='body-xs'
sx={{
mb: 0.75,
color: 'text.tertiary',
textTransform: 'uppercase',
letterSpacing: '0.05em',
}}
>
{t('duePicker.quickTime')}
</Typography>
<List orientation='horizontal' wrap sx={{ ...pillListSx, mb: 1.5 }}>
{[
{
time: '09:00',
label: t('duePicker.morning'),
icon: <LightMode sx={{ fontSize: 14 }} />,
},
{
time: '12:00',
label: t('duePicker.noon'),
icon: <WbSunny sx={{ fontSize: 14 }} />,
},
{
time: '15:00',
label: t('duePicker.afternoon'),
icon: <WbTwilight sx={{ fontSize: 14 }} />,
},
{
time: '18:00',
label: t('duePicker.evening'),
icon: <NightsStay sx={{ fontSize: 14 }} />,
},
{
time: '22:00',
label: t('duePicker.night'),
icon: <Bedtime sx={{ fontSize: 14 }} />,
},
].map(opt => (
<ListItem key={opt.time}>
<Checkbox
checked={localUseCustomTime && localDueTime === opt.time}
onClick={() => handleQuickTime(opt.time)}
overlay
disableIcon
variant='soft'
label={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
{opt.icon}
{opt.label}
</Box>
}
/>
</ListItem>
))}
</List>
<Box
sx={{
mb: 1.5,
borderRadius: 'md',
border: '1px solid',
borderColor: 'neutral.outlinedBorder',
bgcolor: 'background.surface',
p: 1,
// Fix the height so switching views (month/year/decade) doesn't
// cause layout shift — month view with 6 rows is the tallest.
minHeight: 300,
display: 'flex',
flexDirection: 'column',
'& .react-calendar': {
flex: 1,
display: 'flex',
flexDirection: 'column',
},
'& .react-calendar__viewContainer': {
flex: 1,
display: 'flex',
flexDirection: 'column',
},
'& .react-calendar__month-view, & .react-calendar__year-view, & .react-calendar__decade-view, & .react-calendar__century-view':
{
flex: 1,
},
// Navigation row
'& .react-calendar__navigation': {
display: 'flex',
alignItems: 'center',
gap: '4px',
mb: 1,
},
// All nav buttons — large tap targets
'& .react-calendar__navigation button': {
background: 'none',
border: 'none',
borderRadius: '8px',
color: 'var(--joy-palette-text-primary)',
fontFamily: 'var(--joy-fontFamily-body)',
fontSize: '0.875rem',
fontWeight: 600,
cursor: 'pointer',
minHeight: '40px',
minWidth: '40px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '0 8px',
transition: 'background 0.15s',
'&:hover': {
backgroundColor: 'var(--joy-palette-neutral-softBg)',
},
'&:disabled': {
opacity: 0.35,
cursor: 'default',
},
},
// Label button (month/year text) takes remaining space
'& .react-calendar__navigation__label': {
flex: 1,
fontSize: '0.9rem',
fontWeight: 700,
letterSpacing: '0.01em',
},
// Prev/next arrow buttons — slightly larger icon feel
'& .react-calendar__navigation__prev-button, & .react-calendar__navigation__next-button':
{
fontSize: '1.75rem',
},
'& .react-calendar__navigation__prev2-button, & .react-calendar__navigation__next2-button':
{
fontSize: '1.4rem',
},
// Weekday headers
'& .react-calendar__month-view__weekdays__weekday': {
fontSize: '0.7rem',
fontWeight: 600,
color: 'var(--joy-palette-text-tertiary)',
textAlign: 'center',
padding: '4px 0',
textTransform: 'uppercase',
letterSpacing: '0.04em',
},
'& .react-calendar__month-view__weekdays__weekday abbr': {
textDecoration: 'none',
},
// All tiles — shared base
'& .react-calendar__tile': {
border: 'none',
background: 'none',
color: 'var(--joy-palette-text-primary)',
fontFamily: 'var(--joy-fontFamily-body)',
fontSize: '0.8rem',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
transition: 'background 0.15s',
'&:hover': {
background: 'var(--joy-palette-neutral-softBg)',
},
},
// Day tiles only — circular
'& .react-calendar__month-view__days .react-calendar__tile': {
aspectRatio: '1',
borderRadius: '50%',
},
// Month tiles (year view) — pill shape, no huge circle
'& .react-calendar__year-view .react-calendar__tile': {
borderRadius: '8px',
padding: '10px 4px',
fontSize: '0.875rem',
},
// Year tiles (decade view) — pill shape
'& .react-calendar__decade-view .react-calendar__tile': {
borderRadius: '8px',
padding: '10px 4px',
fontSize: '0.875rem',
},
// Century tiles — pill shape
'& .react-calendar__century-view .react-calendar__tile': {
borderRadius: '8px',
padding: '10px 4px',
fontSize: '0.875rem',
},
'& .react-calendar__tile--now': {
border:
'1.5px solid var(--joy-palette-primary-solidBg) !important',
color: 'var(--joy-palette-primary-solidBg) !important',
fontWeight: 700,
background: 'none !important',
},
'& .react-calendar__tile--active, & .react-calendar__tile--active:hover':
{
background: 'var(--joy-palette-primary-solidBg) !important',
color: 'var(--joy-palette-primary-solidColor) !important',
fontWeight: 700,
},
'& .react-calendar__month-view__days__day--neighboringMonth': {
color: 'var(--joy-palette-text-tertiary)',
},
'& .react-calendar__month-view__days': {
display: 'grid !important',
gridTemplateColumns: 'repeat(7, 1fr) !important',
},
'& .react-calendar__month-view__weekdays': {
display: 'grid !important',
gridTemplateColumns: 'repeat(7, 1fr) !important',
},
}}
>
<Calendar
value={
localDueDateOnly ? new Date(`${localDueDateOnly}T00:00:00`) : null
}
calendarType={calendarType}
onChange={handleCalendarChange}
formatShortWeekday={(locale, date) =>
['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'][date.getDay()]
}
formatMonth={(locale, date) =>
[
'Jan',
'Feb',
'Mar',
'Apr',
'May',
'Jun',
'Jul',
'Aug',
'Sep',
'Oct',
'Nov',
'Dec',
][date.getMonth()]
}
/>
</Box>
<Typography
level='body-xs'
sx={{
mb: 0.5,
mt: 0.5,
color: 'text.tertiary',
textTransform: 'uppercase',
letterSpacing: '0.05em',
}}
>
{t('duePicker.customTime')}
</Typography>
<Input
type='time'
size='sm'
value={localUseCustomTime ? localDueTime || '' : ''}
disabled={!localDueDateOnly}
onChange={handleLocalTimeInputChange}
sx={{ maxWidth: 200, mb: 1 }}
slotProps={{ input: { style: { fontFamily: 'inherit' } } }}
/>
<Box sx={{ display: 'flex', gap: 0.75, mb: 0.5 }}>
<Button
size='sm'
variant={!localUseCustomTime ? 'soft' : 'plain'}
color='neutral'
disabled={!localDueDateOnly}
onClick={() => setLocalUseCustomTime(false)}
>
{t('duePicker.anytime')}
</Button>
<Button
size='sm'
variant={localUseCustomTime ? 'soft' : 'plain'}
color='neutral'
disabled={!localDueDateOnly}
onClick={() => setLocalUseCustomTime(true)}
>
{t('duePicker.specificTime')}
</Button>
</Box>
</Box>
</ResponsiveModal>
)
}
export default DueDatePickerModal