-
- Add an extra layer of security to your account with multi-factor
- authentication (MFA). When enabled, you'll need to provide a
- verification code from your authenticator app in addition to your
- password when signing in.
-
+ {t('mfa.description')}
{success && (
setSuccess('')}>
@@ -204,13 +200,11 @@ const MFASettings = () => {
-
- Two-Factor Authentication
-
+ {t('mfa.twoFactor')}
{mfaEnabled
- ? 'Your account is protected with 2FA'
- : 'Secure your account with an authenticator app'}
+ ? t('mfa.enabledSubtitle')
+ : t('mfa.disabledSubtitle')}
@@ -221,7 +215,7 @@ const MFASettings = () => {
variant='outlined'
onClick={() => setDisableModalOpen(true)}
>
- Disable
+ {t('mfa.disable')}
) : (
)}
@@ -265,23 +259,29 @@ const MFASettings = () => {
setSetupStep(2),
startDecorator: ,
}}
/>
) : setupStep === 2 ? (
setSetupStep(1) }}
+ secondary={{
+ label: t('mfa.setup.back'),
+ onClick: () => setSetupStep(1),
+ }}
primary={{
- label: 'Verify & Enable',
+ label: t('mfa.setup.verifyAndEnable'),
onClick: handleConfirmMFA,
disabled: verificationCode.length !== 6,
}}
@@ -289,7 +289,7 @@ const MFASettings = () => {
) : (
@@ -299,8 +299,8 @@ const MFASettings = () => {
{setupStep === 1 && setupData && (
- Step 1: Scan the QR code below with your
- authenticator app (Google Authenticator, Authy, etc.)
+ {t('mfa.setup.step1Label')}{' '}
+ {t('mfa.setup.step1')}
@@ -310,14 +310,11 @@ const MFASettings = () => {
qrCodeDataUrl ||
`data:image/png;base64,${setupData.qrCode}`
}
- alt='MFA QR Code'
+ alt={t('mfa.setup.qrAlt')}
style={{ maxWidth: '200px', maxHeight: '200px' }}
/>
) : (
-
- QR code could not be generated. Please try again or use the
- manual entry key below.
-
+ {t('mfa.setup.qrFailed')}
)}
@@ -331,7 +328,7 @@ const MFASettings = () => {
}}
>
- Manual entry key:
+ {t('mfa.setup.manualKey')} {
{setupStep === 2 && (
- Step 2: Enter the 6-digit verification code
- from your authenticator app
+ {t('mfa.setup.step2Label')}{' '}
+ {t('mfa.setup.step2')}
{
- MFA Successfully Enabled!
+ {t('mfa.setup.successTitle')}
- Save these backup codes in a safe place
+ {t('mfa.setup.backupCodesTitle')}
- You can use these codes to access your account if you lose
- your authenticator device. Each code can only be used once.
+ {t('mfa.setup.backupCodesDescription')}
@@ -418,15 +414,18 @@ const MFASettings = () => {
{
- Disabling MFA will make your account less secure. Are you sure
- you want to continue?
+ {t('mfa.disableModal.warning')}
- Enter a verification code from your authenticator app to confirm:
+ {t('mfa.disableModal.prompt')}
{
@@ -477,12 +475,12 @@ const MFASettings = () => {
setBackupCodesModalOpen(false)}
- title='New Backup Codes'
+ title={t('mfa.backupCodesModal.title')}
size='sm'
footer={
setBackupCodesModalOpen(false),
}}
/>
@@ -491,8 +489,7 @@ const MFASettings = () => {
- Your previous backup codes are now invalid. Save these new codes
- in a safe place. Each code can only be used once.
+ {t('mfa.backupCodesModal.warning')}
diff --git a/src/views/Settings/NotificationSetting.jsx b/src/views/Settings/NotificationSetting.jsx
index ee22a23..d225083 100644
--- a/src/views/Settings/NotificationSetting.jsx
+++ b/src/views/Settings/NotificationSetting.jsx
@@ -2,6 +2,7 @@ import { Capacitor } from '@capacitor/core'
import { Device } from '@capacitor/device'
import { LocalNotifications } from '@capacitor/local-notifications'
import { Preferences } from '@capacitor/preferences'
+import { PushNotifications } from '@capacitor/push-notifications'
import { Android, Apple } from '@mui/icons-material'
import {
Box,
@@ -18,9 +19,10 @@ import {
Typography,
} from '@mui/joy'
import { useEffect, useState } from 'react'
+import { useTranslation } from 'react-i18next'
-import { PushNotifications } from '@capacitor/push-notifications'
import { registerPushNotifications } from '../../CapacitorListener'
+import { useLocalization } from '../../contexts/LocalizationContext'
import { useDeviceTokens, useUserProfile } from '../../queries/UserQueries'
import { useNotification } from '../../service/NotificationProvider'
import { isOfficialDonetickInstanceSync } from '../../utils/FeatureToggle'
@@ -31,6 +33,8 @@ import {
import SettingsLayout from './SettingsLayout'
const NotificationSetting = () => {
+ const { t } = useTranslation('settings')
+ const { fmt } = useLocalization()
const { showWarning } = useNotification()
const { data: userProfile, refetch: refetchUserProfile } = useUserProfile()
const { data: deviceTokens, refetch: refetchDevices } = useDeviceTokens()
@@ -149,26 +153,23 @@ const NotificationSetting = () => {
const handleDeviceRegistered = () => {
refetchDevices()
showWarning({
- title: 'Success',
- message: 'Device registered successfully for push notifications.',
+ title: t('common.success'),
+ message: t('notifications.deviceRegistered'),
})
}
const handleDeviceRegistrationFailed = event => {
- const { status, error } = event.detail || {}
+ const { error, status } = event.detail || {}
if (status === 409) {
showWarning({
- title: 'Device Limit Reached',
- message:
- 'You have reached the maximum limit of 5 registered devices. Please remove a device before registering this one.',
+ title: t('notifications.deviceLimitTitle'),
+ message: t('notifications.deviceLimitMessage'),
})
} else {
showWarning({
- title: 'Registration Failed',
- message:
- error ||
- 'Failed to register device automatically. Please try again.',
+ title: t('notifications.registrationFailedTitle'),
+ message: error || t('notifications.registrationFailedMessage'),
})
}
}
@@ -195,16 +196,16 @@ const NotificationSetting = () => {
switch (notificationTarget) {
case '1':
if (chatID === '') {
- setError('Chat ID is required')
+ setError(t('notifications.chatIdRequired'))
return false
} else if (isNaN(chatID) || chatID === '0') {
- setError('Invalid Chat ID')
+ setError(t('notifications.chatIdInvalid'))
return false
}
break
case '2':
if (chatID === '') {
- setError('User key is required')
+ setError(t('notifications.userKeyRequired'))
return false
}
break
@@ -222,12 +223,12 @@ const NotificationSetting = () => {
type: Number(notificationTarget),
}).then(resp => {
if (resp.status != 200) {
- alert(`Error while updating notification target: ${resp.statusText}`)
+ alert(t('notifications.targetUpdateFailed', { error: resp.statusText }))
return
}
refetchUserProfile()
- alert('Notification target updated')
+ alert(t('notifications.targetUpdated'))
})
}
@@ -238,9 +239,8 @@ const NotificationSetting = () => {
const currentDeviceCount = deviceTokens ? deviceTokens.length : 0
if (currentDeviceCount >= 5) {
showWarning({
- title: 'Device Limit Reached',
- message:
- 'You have reached the maximum limit of 5 registered devices. Please remove a device before registering this one.',
+ title: t('notifications.deviceLimitTitle'),
+ message: t('notifications.deviceLimitMessage'),
})
return
}
@@ -251,9 +251,8 @@ const NotificationSetting = () => {
if (permStatus.receive !== 'granted') {
showWarning({
- title: 'Permission Required',
- message:
- 'Push notification permission is required to register this device.',
+ title: t('notifications.permissionRequiredTitle'),
+ message: t('notifications.permissionRequiredMessage'),
})
return
}
@@ -267,25 +266,25 @@ const NotificationSetting = () => {
setPushNotification(true)
showWarning({
- title: 'Registration Initiated',
- message:
- 'Push notification registration has been initiated. The device will be registered automatically.',
+ title: t('notifications.registrationInitiatedTitle'),
+ message: t('notifications.registrationInitiatedMessage'),
})
} catch (error) {
console.error('Error registering device:', error)
showWarning({
- title: 'Error',
- message: 'Failed to register device. Please try again.',
+ title: t('common.error'),
+ message: t('notifications.registerDeviceFailed'),
})
}
}
return (
-
-
+
- Device Notification
+ {t('notifications.deviceSection')}
- Manage your Device Notification
+
+ {t('notifications.deviceSectionDescription')}
+ {
setNotificationPreferences({ granted: true })
} else if (resp.display === 'denied') {
showWarning({
- title: 'Notification Permission Denied',
- message:
- 'You have denied notification permissions. You can enable them later in your device settings.',
+ title: t('notifications.permissionDeniedTitle'),
+ message: t('notifications.permissionDeniedMessage'),
})
setDeviceNotification(false)
setNotificationPreferences({ granted: false })
@@ -324,11 +322,11 @@ const NotificationSetting = () => {
sx={{ mr: 2 }}
/>
- Device Notification
+ {t('notifications.deviceLabel')}
{Capacitor.isNativePlatform()
- ? 'Receive notification on your device when a task is due'
- : 'This feature is only available on mobile devices'}{' '}
+ ? t('notifications.deviceHelper')
+ : t('notifications.mobileOnly')}{' '}
- Push Notifications
+ {t('notifications.pushLabel')}
{Capacitor.isNativePlatform()
- ? 'Receive Nudges, Announcements, and Chore Assignments via Push Notifications'
- : 'This feature is only available on mobile devices'}{' '}
+ ? t('notifications.pushHelper')
+ : t('notifications.mobileOnly')}{' '}
- Custom Notification
+ {t('notifications.customLabel')}
- Receive notification on other platform
+ {t('notifications.customHelper')}
@@ -668,16 +673,15 @@ const NotificationSetting = () => {
sx={{ maxWidth: '200px' }}
onChange={(e, selected) => setNotificationTarget(selected)}
>
-
-
-
-
+
+
+
+
{notificationTarget === '1' && (
<>
- You need to initiate a message to the bot in order for the
- Telegram notification to work{' '}
+ {t('notifications.telegramBotHelpBefore')}{' '}
{
}}
href='https://t.me/DonetickBot'
>
- Click here
+ {t('notifications.clickHere')}
{' '}
- to start a chat
+ {t('notifications.telegramBotHelpAfter')}
- Chat ID
+
+ {t('notifications.chatId')}
+
setChatID(e.target.value)}
- placeholder='User ID / Chat ID'
+ placeholder={t('notifications.chatIdPlaceholder')}
sx={{
width: '200px',
}}
/>
- If you don't know your Chat ID, start chat with userinfobot
- and it will send you your Chat ID.{' '}
+ {t('notifications.telegramChatIdHelpBefore')}{' '}
{
}}
href='https://t.me/userinfobot'
>
- Click here
+ {t('notifications.clickHere')}
{' '}
- to start chat with userinfobot{' '}
+ {t('notifications.telegramChatIdHelpAfter')}{' '}
>
)}
{notificationTarget === '2' && (
<>
- User key
+
+ {t('notifications.userKey')}
+
setChatID(e.target.value)}
- placeholder='User ID'
+ placeholder={t('notifications.userKeyPlaceholder')}
sx={{
width: '200px',
}}
@@ -742,7 +749,7 @@ const NotificationSetting = () => {
}}
onClick={handleSave}
>
- Save
+ {t('common.save')}
)}
diff --git a/src/views/Settings/ProfileSettings.jsx b/src/views/Settings/ProfileSettings.jsx
index 479b168..985e722 100644
--- a/src/views/Settings/ProfileSettings.jsx
+++ b/src/views/Settings/ProfileSettings.jsx
@@ -180,7 +180,7 @@ const ProfileSettings = () => {
setShowCropper(false)
setSelectedFile(null)
}}
- title={t('profile.editPhoto', { defaultValue: 'Edit profile photo' })}
+ title={t('profile.editPhoto')}
size='sm'
closeOnBackdrop={!isUploading}
closeOnEscape={!isUploading}
diff --git a/src/views/Settings/SettingsOverview.jsx b/src/views/Settings/SettingsOverview.jsx
index 378e919..0e5ae66 100644
--- a/src/views/Settings/SettingsOverview.jsx
+++ b/src/views/Settings/SettingsOverview.jsx
@@ -373,7 +373,7 @@ const SettingsOverview = () => {
borderColor: 'warning.main',
}}
>
- Early Access
+ {t('common.earlyAccess')}
)}
diff --git a/src/views/Settings/SidepanelSettings.jsx b/src/views/Settings/SidepanelSettings.jsx
index 6467858..b683625 100644
--- a/src/views/Settings/SidepanelSettings.jsx
+++ b/src/views/Settings/SidepanelSettings.jsx
@@ -26,6 +26,8 @@ import {
Typography,
} from '@mui/joy'
import { useEffect, useState } from 'react'
+import { useTranslation } from 'react-i18next'
+
import {
DEFAULT_SIDEPANEL_CONFIG,
getSidepanelConfig,
@@ -34,8 +36,15 @@ import {
import SettingsLayout from './SettingsLayout'
const SidepanelSettings = () => {
+ const { t } = useTranslation('settings')
const [config, setConfig] = useState(getSidepanelConfig())
+ // Card names/descriptions live in the config so they can be persisted, but the
+ // stored copy is English. Prefer the translated string and fall back to it.
+ const cardName = item => t(`sidepanel.cards.${item.id}.name`, item.name)
+ const cardDescription = item =>
+ t(`sidepanel.cards.${item.id}.description`, item.description)
+
const getIcon = iconName => {
switch (iconName) {
case 'SupervisorAccount':
@@ -93,15 +102,14 @@ const SidepanelSettings = () => {
}
return (
-
+
- Sidepanel Settings
+ {t('sidepanel.heading')}
- Customize which cards appear in the sidepanel and their order. Drag
- and drop to reorder, or toggle visibility for each card.
+ {t('sidepanel.description')}
@@ -176,7 +184,7 @@ const SidepanelSettings = () => {
level='title-sm'
sx={{ fontWeight: 600 }}
>
- {item.name}
+ {cardName(item)}
{
color: 'var(--joy-palette-text-tertiary)',
}}
>
- - {item.description}
+ - {cardDescription(item)}
@@ -226,10 +234,10 @@ const SidepanelSettings = () => {
onClick={resetToDefaults}
size='sm'
>
- Reset to Defaults
+ {t('sidepanel.resetToDefaults')}
- This will restore all cards to their default visibility and order.
+ {t('sidepanel.resetHelper')}
diff --git a/src/views/Settings/StorageSettings.jsx b/src/views/Settings/StorageSettings.jsx
index 284710b..7b36b2d 100644
--- a/src/views/Settings/StorageSettings.jsx
+++ b/src/views/Settings/StorageSettings.jsx
@@ -1,13 +1,9 @@
import { Capacitor } from '@capacitor/core'
-import {
- Button,
- Card,
- Chip,
- LinearProgress,
- Typography,
-} from '@mui/joy'
+import { Button, Card, Chip, LinearProgress, Typography } from '@mui/joy'
import { useEffect, useState } from 'react'
+import { useTranslation } from 'react-i18next'
import { useNavigate } from 'react-router-dom'
+
import { useUserProfile } from '../../queries/UserQueries'
import { GetStorageUsage } from '../../utils/Fetcher'
import { isPlusAccount } from '../../utils/Helpers'
@@ -15,6 +11,7 @@ import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
import SettingsLayout from './SettingsLayout'
const StorageSettings = () => {
+ const { t } = useTranslation('settings')
const Navigate = useNavigate()
const { data: userProfile } = useUserProfile()
const [usage, setUsage] = useState({ used: 0, total: 0 })
@@ -25,8 +22,8 @@ const StorageSettings = () => {
message,
title,
onConfirm,
- confirmText = 'Confirm',
- cancelText = 'Cancel',
+ confirmText = t('common.confirm'),
+ cancelText = t('common.cancel'),
color = 'primary',
) => {
setConfirmModalConfig({
@@ -62,20 +59,19 @@ const StorageSettings = () => {
const totalMB = (usage.total / (1024 * 1024)).toFixed(2)
return (
-
+
- Server Storage Usage
+ {t('storage.serverTitle')}
{!isPlusAccount(userProfile) && (
- Plus Feature
+ {t('common.plusFeature')}
)}
- This is the storage used by your account on our servers (e.g. files,
- images, and data you have uploaded).
+ {t('storage.serverDescription')}
{!isPlusAccount(userProfile) ? (
<>
@@ -91,23 +87,26 @@ const StorageSettings = () => {
}}
/>
- -- MB used / -- MB total (--)
+ {t('storage.usagePlaceholder')}
- Server storage is not available in the Basic plan. Upgrade to
- Plus to track your server storage usage.
+ {t('storage.basicPlanNotice')}
>
) : loading ? (
<>
- Loading...
+ {t('common.loading')}
>
) : (
<>
- {usedMB} MB used / {totalMB} MB total ({percent}%)
+ {t('storage.usage', {
+ used: usedMB,
+ total: totalMB,
+ percent,
+ })}
>
)}
@@ -115,72 +114,69 @@ const StorageSettings = () => {
- {Capacitor.isNativePlatform() ? 'App' : 'Browser'} Local Storage &
- Cache
+ {Capacitor.isNativePlatform()
+ ? t('storage.localTitleApp')
+ : t('storage.localTitleBrowser')}
- This is data stored locally in your browser for faster access.
- Clearing this will not affect your server data, but may log you out.
+ {t('storage.localDescription')}
{Capacitor.isNativePlatform() && (
- App Preferences
+ {t('storage.appPreferences')}
- Device Only
+ {t('storage.deviceOnly')}
- These are preferences and settings stored locally on your device
- by the app. Clearing them will reset app-specific settings and may
- log you out, but will not affect your server data.
+ {t('storage.appPreferencesDescription')}
)}
diff --git a/src/views/Settings/ThemeSettings.jsx b/src/views/Settings/ThemeSettings.jsx
index 06f1d7b..2a4793f 100644
--- a/src/views/Settings/ThemeSettings.jsx
+++ b/src/views/Settings/ThemeSettings.jsx
@@ -1,19 +1,20 @@
import { Typography } from '@mui/joy'
+import { useTranslation } from 'react-i18next'
+
import SettingsLayout from './SettingsLayout'
import ThemeToggle from './ThemeToggle'
const ThemeSettings = () => {
+ const { t } = useTranslation('settings')
+
return (
-
+
-
- Choose how the site looks to you. Select a single theme, or sync with
- your system and automatically switch between day and night themes.
-
+ {t('theme.description')}
)
}
-export default ThemeSettings
\ No newline at end of file
+export default ThemeSettings
diff --git a/src/views/TestView/AutocompleteDropdown.jsx b/src/views/TestView/AutocompleteDropdown.jsx
index 685800e..aeb77a9 100644
--- a/src/views/TestView/AutocompleteDropdown.jsx
+++ b/src/views/TestView/AutocompleteDropdown.jsx
@@ -3,14 +3,16 @@ import { Add } from '@mui/icons-material'
import { Divider, Menu, MenuItem } from '@mui/joy'
import React, { useEffect } from 'react'
+import { Z_INDEX } from '../../constants/zIndex'
+
const AutocompleteDropdown = ({
currentValue,
- suggestions,
- selectedIndex,
- onSelectSuggestion,
- onMouseEnterSuggestion, // Added for hover selection
onCreateSuggestion, // Called when the "Create new" row is chosen
+ onMouseEnterSuggestion, // Added for hover selection
+ onSelectSuggestion,
parentRefer, // Ref to the dropdown element
+ selectedIndex,
+ suggestions,
}) => {
// Scroll selected item into view
const dropdownMenuRef = React.useRef(null)
@@ -60,7 +62,7 @@ const AutocompleteDropdown = ({
position: 'relative',
bottom: 0,
left: 0,
- zIndex: 1300,
+ zIndex: Z_INDEX.MODAL_POPOVER,
}}
>
{filteredOptions.map((option, index) => (
diff --git a/src/views/components/AddTaskModal.jsx b/src/views/components/AddTaskModal.jsx
index 500f7a9..95e440d 100644
--- a/src/views/components/AddTaskModal.jsx
+++ b/src/views/components/AddTaskModal.jsx
@@ -5,6 +5,7 @@ import { useQueryClient } from '@tanstack/react-query'
import * as chrono from 'chrono-node'
import moment from 'moment'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
+import { flushSync } from 'react-dom'
import KeyboardShortcutHint from '../../components/common/KeyboardShortcutHint'
import ModalActions from '../../components/common/ModalActions'
@@ -37,7 +38,6 @@ import {
} from './CustomParsers'
import DueDatePickerField from './DueDatePickerField'
import LabelsPickerField from './LabelsPickerField'
-import LearnMoreButton from './LearnMore'
import NotificationPickerField from './NotificationPickerField'
import PriorityPickerField from './PriorityPickerField'
import RepeatPickerField from './RepeatPickerField'
@@ -108,6 +108,66 @@ const getDefaultNotification = () => {
return DEFAULT_NOTIFICATION_TEMPLATES
}
+// Get initial project from localStorage (current active project)
+const getInitialProject = () => {
+ const saved = localStorage.getItem('selectedProject')
+ if (saved) {
+ try {
+ const project = JSON.parse(saved)
+ return project?.id || 'default'
+ } catch {
+ return 'default'
+ }
+ }
+ return 'default'
+}
+
+const PRIORITY_COLORS = {
+ 0: TASK_COLOR.NO_PRIORITY,
+ 1: TASK_COLOR.PRIORITY_1,
+ 2: TASK_COLOR.PRIORITY_2,
+ 3: TASK_COLOR.PRIORITY_3,
+ 4: TASK_COLOR.PRIORITY_4,
+}
+
+const PRIORITY_LABELS = {
+ 0: '--',
+ 1: 'P1',
+ 2: 'P2',
+ 3: 'P3',
+ 4: 'P4',
+}
+
+// Static option sets for the smart input's trigger suggestions
+const PRIORITY_SUGGESTIONS = {
+ value: 'id',
+ display: 'name',
+ options: [
+ { id: '1', name: 'P1' },
+ { id: '2', name: 'P2' },
+ { id: '3', name: 'P3' },
+ { id: '4', name: 'P4' },
+ ],
+}
+
+const POINTS_SUGGESTIONS = {
+ value: 'id',
+ display: 'name',
+ options: [
+ { id: '1', name: '1 point' },
+ { id: '5', name: '5 points' },
+ { id: '10', name: '10 points' },
+ { id: '25', name: '25 points' },
+ { id: '50', name: '50 points' },
+ { id: '100', name: '100 points' },
+ ],
+}
+
+// Delay between the last keystroke and the smart-input parse. Parsing (chrono
+// especially) is too heavy to run per keystroke; submitChore flushes a pending
+// parse so a fast type-then-Enter never creates from stale parsed state.
+const PARSE_DEBOUNCE_MS = 150
+
const TaskInput = ({ initialMode, isModalOpen, onChoreUpdate, onClose }) => {
const { ResponsiveModal } = useResponsiveModal()
const isMobile = useMediaQuery(theme => theme.breakpoints.down('sm'))
@@ -138,38 +198,85 @@ const TaskInput = ({ initialMode, isModalOpen, onChoreUpdate, onClose }) => {
[queryClient],
)
- // Get initial project from localStorage (current active project)
- const getInitialProject = () => {
- const saved = localStorage.getItem('selectedProject')
- if (saved) {
- try {
- const project = JSON.parse(saved)
- return project?.id || 'default'
- } catch {
- return 'default'
- }
- }
- return 'default'
- }
+ const smartInputSuggestions = useMemo(
+ () => ({
+ '#': {
+ value: 'id',
+ display: 'name',
+ options: userLabels || [],
+ creatable: true,
+ onCreate: handleCreateLabel,
+ },
+ '!': PRIORITY_SUGGESTIONS,
+ '@': {
+ value: 'userId',
+ display: 'displayName',
+ options: [
+ { userId: 'anyone', displayName: 'Anyone' },
+ ...(circleMembers?.res || []),
+ ],
+ },
+ '*': POINTS_SUGGESTIONS,
+ }),
+ [userLabels, circleMembers, handleCreateLabel],
+ )
const [taskText, setTaskText] = useState('')
const [taskTitle, setTaskTitle] = useState('')
- const [renderedParts, setRenderedParts] = useState([])
+ // Highlight spans paired with the text they were computed from: the parse
+ // is debounced, so while typing these lag behind taskText
+ const [renderedParts, setRenderedParts] = useState({ text: '', parts: [] })
+
+ // What the smart input overlay shows. While a parse is pending, keep every
+ // highlight span that precedes the edit point and render the rest as plain
+ // text — existing token styles must not flicker away on each keystroke.
+ const displayedParts = useMemo(() => {
+ const { parts, text } = renderedParts
+ if (text === taskText) return parts
+
+ let prefixLen = 0
+ const max = Math.min(text.length, taskText.length)
+ while (prefixLen < max && text[prefixLen] === taskText[prefixLen]) {
+ prefixLen++
+ }
+
+ const kept = []
+ let consumed = 0
+ for (const part of parts) {
+ const partText = typeof part === 'string' ? part : part.props.children
+ if (consumed + partText.length > prefixLen) break
+ kept.push(part)
+ consumed += partText.length
+ }
+ kept.push(taskText.slice(consumed))
+ return kept
+ }, [renderedParts, taskText])
const richTextEditorRef = useRef(null)
const latestRef = useRef({})
// Picker edits made on a voice task card, applied once after the reparse
// that follows landing the spoken text in the smart input
const pendingVoiceOverridesRef = useRef(null)
+ // True while the current assignees came from an @mention in the text, so a
+ // reparse without mentions only resets what a mention set — never a
+ // selection made directly in the assignee picker
+ const assigneesFromMentionRef = useRef(false)
+ // Pending debounced parse of the smart input text, if any
+ const parseTimerRef = useRef(null)
+ // Identities (type + text) of the highlights from the previous parse, so
+ // the appear animation only plays for tokens detected just now
+ const prevHighlightKeysRef = useRef(new Set())
const [priority, setPriority] = useState(0)
const [dueDate, setDueDate] = useState(null)
const [description, setDescription] = useState(null)
const [assignees, setAssignees] = useState([])
const [labelsV2, setLabelsV2] = useState([])
const [frequency, setFrequency] = useState(null)
- const [notificationMetadata, setNotificationMetadata] = useState({
+ // Lazy initializers: these read localStorage, which must not happen on
+ // every render
+ const [notificationMetadata, setNotificationMetadata] = useState(() => ({
templates: getDefaultNotification(),
- })
+ }))
const [subTasks, setSubTasks] = useState(null)
const [points, setPoints] = useState(-1)
const [isAnyoneTask, setIsAnyoneTask] = useState(false)
@@ -185,7 +292,7 @@ const TaskInput = ({ initialMode, isModalOpen, onChoreUpdate, onClose }) => {
const [dueTime, setDueTime] = useState(null)
const [useCustomTime, setUseCustomTime] = useState(false)
const [showKeyboardShortcuts, setShowKeyboardShortcuts] = useState(false)
- const [projectId, setProjectId] = useState(getInitialProject())
+ const [projectId, setProjectId] = useState(getInitialProject)
const [attachments, setAttachments] = useState([])
const [draftId, setDraftId] = useState(() => generateUUID())
@@ -251,23 +358,6 @@ const TaskInput = ({ initialMode, isModalOpen, onChoreUpdate, onClose }) => {
}
}, [isModalOpen, initialMode, voiceAvailable, llmAvailable])
- // Priority colors
- const priorityColors = {
- 0: TASK_COLOR.NO_PRIORITY,
- 1: TASK_COLOR.PRIORITY_1,
- 2: TASK_COLOR.PRIORITY_2,
- 3: TASK_COLOR.PRIORITY_3,
- 4: TASK_COLOR.PRIORITY_4,
- }
-
- const priorityLabels = {
- 0: '--',
- 1: 'P1',
- 2: 'P2',
- 3: 'P3',
- 4: 'P4',
- }
-
// set showKeyboardShortcuts true as soon as the user hold ctrl or cmd key:
useEffect(() => {
if (hasDescription && richTextEditorRef.current) {
@@ -281,11 +371,11 @@ const TaskInput = ({ initialMode, isModalOpen, onChoreUpdate, onClose }) => {
useEffect(() => {
const handleKeyDown = event => {
const {
- createChore,
dueDate,
handleCloseModal,
hasDescription,
isModalOpen,
+ submitChore,
} = latestRef.current
const isHoldingCmd = event.ctrlKey || event.metaKey
if (isHoldingCmd) {
@@ -323,7 +413,7 @@ const TaskInput = ({ initialMode, isModalOpen, onChoreUpdate, onClose }) => {
isModalOpen
) {
event.preventDefault()
- createChore()
+ submitChore()
return
}
if (event.key === 'Escape' && isModalOpen) {
@@ -411,6 +501,7 @@ const TaskInput = ({ initialMode, isModalOpen, onChoreUpdate, onClose }) => {
}
}
+ const seenHighlightKeys = new Set()
for (const highlight of resolvedHighlights) {
if (highlight.start > lastIndex) {
const textBefore = sentence.substring(lastIndex, highlight.start)
@@ -446,10 +537,13 @@ const TaskInput = ({ initialMode, isModalOpen, onChoreUpdate, onClose }) => {
highlight.start,
highlight.end,
)
+ const highlightKey = `${highlight.type}:${highlightedText.toLowerCase()}`
+ const isNewHighlight = !prevHighlightKeysRef.current.has(highlightKey)
+ seenHighlightKeys.add(highlightKey)
parts.push(
{
lastIndex = highlight.end
}
+ prevHighlightKeysRef.current = seenHighlightKeys
if (lastIndex < sentence.length) {
const remainingText = sentence.substring(lastIndex)
@@ -477,14 +572,11 @@ const TaskInput = ({ initialMode, isModalOpen, onChoreUpdate, onClose }) => {
[],
)
- const processText = useCallback(
- sentence => {
- const priority = parsePriority(sentence)
- const pointsParsed = parsePoints(sentence)
- const labels = parseLabels(sentence, userLabels || [])
-
- const circleMembersList = circleMembers?.res || []
- const assigneesForParsing = circleMembersList.map(member => ({
+ // Rebuilt only when the member list actually changes, so a query refetch
+ // with identical data doesn't re-trigger the parse effect below
+ const assigneesForParsing = useMemo(
+ () =>
+ (circleMembers?.res || []).map(member => ({
userId: member.userId,
username:
member.username ||
@@ -492,7 +584,15 @@ const TaskInput = ({ initialMode, isModalOpen, onChoreUpdate, onClose }) => {
displayName: member.displayName,
name: member.displayName,
id: member.userId,
- }))
+ })),
+ [circleMembers],
+ )
+
+ const processText = useCallback(
+ sentence => {
+ const priority = parsePriority(sentence)
+ const pointsParsed = parsePoints(sentence)
+ const labels = parseLabels(sentence, userLabels || [])
const assigneesResult = parseAssignees(sentence, assigneesForParsing)
const repeat = parseRepeatV2(sentence)
@@ -510,14 +610,18 @@ const TaskInput = ({ initialMode, isModalOpen, onChoreUpdate, onClose }) => {
// @Anyone was used - set empty assignees (anyone can do the task)
setIsAnyoneTask(true)
setAssignees([])
+ assigneesFromMentionRef.current = true
} else if (assigneesResult.result && assigneesResult.result.length > 0) {
setIsAnyoneTask(false)
const parsedAssignees = assigneesResult.result.map(assignee => ({
userId: assignee.userId,
}))
setAssignees(parsedAssignees)
- } else {
- // Only assign to current user if no @ mentions found and userProfile exists
+ assigneesFromMentionRef.current = true
+ } else if (assigneesFromMentionRef.current) {
+ // The @mention that set the current assignees was deleted — fall back
+ // to the implicit self default. Picker selections stay untouched.
+ assigneesFromMentionRef.current = false
setIsAnyoneTask(false)
if (userProfile?.id) {
setAssignees([
@@ -555,39 +659,47 @@ const TaskInput = ({ initialMode, isModalOpen, onChoreUpdate, onClose }) => {
syncDueDateStates(repeat.dueDate)
}
- // Create the cleaned sentence by sequentially applying all cleanups
+ // Create the cleaned sentence by sequentially applying all cleanups.
+ // Each stage only needs a reparse when an earlier cleanup actually
+ // changed the sentence; otherwise the first-pass result (computed on the
+ // identical string) is reused as-is.
let cleanedSentence = sentence
if (priority.result) cleanedSentence = priority.cleanedSentence
if (pointsParsed.result) {
- // Apply points cleaning to the current cleaned sentence
- const pointsReparse = parsePoints(cleanedSentence)
+ const pointsReparse =
+ cleanedSentence === sentence
+ ? pointsParsed
+ : parsePoints(cleanedSentence)
if (pointsReparse.result)
cleanedSentence = pointsReparse.cleanedSentence
}
if (labels.result) {
- // Apply labels cleaning to the current cleaned sentence
- const labelsReparse = parseLabels(cleanedSentence, userLabels || [])
+ const labelsReparse =
+ cleanedSentence === sentence
+ ? labels
+ : parseLabels(cleanedSentence, userLabels || [])
if (labelsReparse.result)
cleanedSentence = labelsReparse.cleanedSentence
}
if (assigneesResult.result) {
- // Apply assignees cleaning to the current cleaned sentence
- const assigneesReparse = parseAssignees(
- cleanedSentence,
- assigneesForParsing,
- )
+ const assigneesReparse =
+ cleanedSentence === sentence
+ ? assigneesResult
+ : parseAssignees(cleanedSentence, assigneesForParsing)
if (assigneesReparse.result)
cleanedSentence = assigneesReparse.cleanedSentence
}
if (repeat.result) {
- // Apply repeat cleaning to the current cleaned sentence
- const repeatReparse = parseRepeatV2(cleanedSentence)
+ const repeatReparse =
+ cleanedSentence === sentence ? repeat : parseRepeatV2(cleanedSentence)
if (repeatReparse.result)
cleanedSentence = repeatReparse.cleanedSentence
}
if (dueDateParsed.result) {
- // Apply date cleaning to the current cleaned sentence
- const dueDateReparse = parseDueDate(cleanedSentence, chrono)
+ const dueDateReparse =
+ cleanedSentence === sentence
+ ? dueDateParsed
+ : parseDueDate(cleanedSentence, chrono)
if (dueDateReparse.result)
cleanedSentence = dueDateReparse.cleanedSentence
}
@@ -606,7 +718,7 @@ const TaskInput = ({ initialMode, isModalOpen, onChoreUpdate, onClose }) => {
assigneesResult.highlight,
)
- setRenderedParts(parts)
+ setRenderedParts({ text: sentence, parts })
const overrides = pendingVoiceOverridesRef.current
if (overrides) {
@@ -622,6 +734,7 @@ const TaskInput = ({ initialMode, isModalOpen, onChoreUpdate, onClose }) => {
if ('assignees' in overrides || 'isAnyone' in overrides) {
setIsAnyoneTask(!!overrides.isAnyone)
setAssignees(overrides.assignees || [])
+ assigneesFromMentionRef.current = false
}
if ('dueDate' in overrides) {
if (overrides.dueDate) {
@@ -635,7 +748,7 @@ const TaskInput = ({ initialMode, isModalOpen, onChoreUpdate, onClose }) => {
}
}
},
- [userLabels, renderHighlightedSentence, circleMembers, userProfile],
+ [userLabels, renderHighlightedSentence, assigneesForParsing, userProfile],
)
useEffect(() => {
@@ -648,7 +761,16 @@ const TaskInput = ({ initialMode, isModalOpen, onChoreUpdate, onClose }) => {
return
}
- processText(taskText)
+ // Debounced so fast typing doesn't run the full parse pipeline per
+ // keystroke; submitChore flushes a pending parse before creating.
+ parseTimerRef.current = setTimeout(() => {
+ parseTimerRef.current = null
+ processText(taskText)
+ }, PARSE_DEBOUNCE_MS)
+ return () => {
+ clearTimeout(parseTimerRef.current)
+ parseTimerRef.current = null
+ }
}, [
taskText,
userLabelsLoading,
@@ -713,7 +835,7 @@ const TaskInput = ({ initialMode, isModalOpen, onChoreUpdate, onClose }) => {
}
const handleEnterPressed = () => {
- createChore()
+ submitChore()
}
// The scan keeps its source image when asked: upload it against the draft so
@@ -849,6 +971,10 @@ const TaskInput = ({ initialMode, isModalOpen, onChoreUpdate, onClose }) => {
setHasSubTasks(false)
setLabelsV2([])
setAssignees([])
+ assigneesFromMentionRef.current = false
+ // The modal closes without a final parse, so drop the highlight identities
+ // here or nothing would animate on the next open
+ prevHighlightKeysRef.current = new Set()
setProjectId(getInitialProject())
setDeadlineOffset(-1)
setRequireApproval(false)
@@ -921,8 +1047,12 @@ const TaskInput = ({ initialMode, isModalOpen, onChoreUpdate, onClose }) => {
chore.frequencyMetadata = frequency.frequencyMetadata
chore.frequency = frequency.frequency
}
- if (!frequency && dueDate) {
- // Use RFC3339/ISO-8601 format expected by backend.
+ if (dueDate) {
+ // Use RFC3339/ISO-8601 format expected by backend. The backend only
+ // derives NextDueDate from what's sent on create (handler.go never
+ // computes it from frequencyType), so this must be sent whether or
+ // not the task also repeats — otherwise a recurring task created with
+ // a due date lands with nextDueDate: null.
chore.nextDueDate = new Date(dueDate).toISOString()
}
if (hasReminders && (frequency || dueDate)) {
@@ -954,11 +1084,26 @@ const TaskInput = ({ initialMode, isModalOpen, onChoreUpdate, onClose }) => {
handleCloseModal(false)
}
+ // All submit paths (Enter, Cmd+Enter, footer button) go through here: a
+ // debounce may still be holding the parse of the latest text, and creating
+ // from pre-parse state would drop the tail of what the user typed.
+ const submitChore = () => {
+ if (parseTimerRef.current) {
+ clearTimeout(parseTimerRef.current)
+ parseTimerRef.current = null
+ flushSync(() => processText(taskText))
+ }
+ // Read through latestRef: after the flush, this render's createChore
+ // closure is stale
+ latestRef.current.createChore()
+ }
+
latestRef.current = {
isModalOpen,
hasDescription,
dueDate,
createChore,
+ submitChore,
handleCloseModal,
}
@@ -1028,7 +1173,7 @@ const TaskInput = ({ initialMode, isModalOpen, onChoreUpdate, onClose }) => {
color='primary'
loading={isAttachingScan}
disabled={!taskTitle.trim() || isAttachingScan}
- onClick={createChore}
+ onClick={submitChore}
>
Create
{showKeyboardShortcuts && (
@@ -1041,8 +1186,8 @@ const TaskInput = ({ initialMode, isModalOpen, onChoreUpdate, onClose }) => {
>
{!showScan && !showVoice && (
<>
-
-
+ {/* {
>
}
/>
-
+ */}
{
setTaskText(text)
if (!text) setTaskTitle('')
}}
- customRenderer={renderedParts}
+ customRenderer={displayedParts}
onEnterPressed={handleEnterPressed}
onShiftEnterPressed={() => {
if (!hasDescription) {
@@ -1132,45 +1277,7 @@ const TaskInput = ({ initialMode, isModalOpen, onChoreUpdate, onClose }) => {
}
setTimeout(() => richTextEditorRef.current?.focus(), 50)
}}
- suggestions={{
- '#': {
- value: 'id',
- display: 'name',
- options: userLabels ? userLabels : [],
- creatable: true,
- onCreate: handleCreateLabel,
- },
- '!': {
- value: 'id',
- display: 'name',
- options: [
- { id: '1', name: 'P1' },
- { id: '2', name: 'P2' },
- { id: '3', name: 'P3' },
- { id: '4', name: 'P4' },
- ],
- },
- '@': {
- value: 'userId',
- display: 'displayName',
- options: [
- { userId: 'anyone', displayName: 'Anyone' },
- ...(circleMembers?.res || []),
- ],
- },
- '*': {
- value: 'id',
- display: 'name',
- options: [
- { id: '1', name: '1 point' },
- { id: '5', name: '5 points' },
- { id: '10', name: '10 points' },
- { id: '25', name: '25 points' },
- { id: '50', name: '50 points' },
- { id: '100', name: '100 points' },
- ],
- },
- }}
+ suggestions={smartInputSuggestions}
/>
@@ -1212,8 +1319,8 @@ const TaskInput = ({ initialMode, isModalOpen, onChoreUpdate, onClose }) => {
onChange={setPriority}
onClear={() => setPriority(0)}
emptyDisplay={pickerEmptyDisplay}
- priorityColors={priorityColors}
- priorityLabels={priorityLabels}
+ priorityColors={PRIORITY_COLORS}
+ priorityLabels={PRIORITY_LABELS}
/>
{
}}
>
-
- Description
-
+ Description
)}
{!hasSubTasks && (
@@ -1317,9 +1422,7 @@ const TaskInput = ({ initialMode, isModalOpen, onChoreUpdate, onClose }) => {
}}
>
-
- Subtasks
-
+ Subtasks
)}
{
onAssignStrategyChange={setAssignStrategy}
hasDueDate={!!dueDate}
hasMultipleAssignees={assignees.length > 1}
- hasAssignees={assignees.length > 0}
+ // Empty assignees still implicitly assigns the current user at
+ // create time; only an "Anyone" task truly has no assignee
+ hasAssignees={!isAnyoneTask}
isPrivate={isPrivate}
onIsPrivateChange={setIsPrivate}
/>
diff --git a/src/views/components/AdvancedOptionsSection.jsx b/src/views/components/AdvancedOptionsSection.jsx
index 11801eb..e31b019 100644
--- a/src/views/components/AdvancedOptionsSection.jsx
+++ b/src/views/components/AdvancedOptionsSection.jsx
@@ -90,7 +90,6 @@ export const AdvancedOptionsTrigger = ({
{
const { t } = useTranslation('common')
- const { isRTL } = useLocalization()
const { data: resource } = useResource()
+ const { openSearch } = useGlobalSearch()
const navigate = useNavigate()
const [drawerOpen, setDrawerOpen] = useState(false)
const links = [
+ {
+ label: t('navigation.search'),
+ icon: ,
+ onClick: () => openSearch(),
+ },
{
to: '/chores',
label: t('navigation.allTasks'),
@@ -105,6 +110,22 @@ const NavBar = () => {
)
+ if (location.pathname === '/search') {
+ return (
+ {
+ if (window.history.state?.idx > 0) navigate(-1)
+ else navigate('/chores', { replace: true })
+ }}
+ aria-label='Back from search'
+ title={t('back')}
+ >
+
+
+ )
+ }
if (!Capacitor.isNativePlatform()) {
return menuRounded
}
@@ -133,7 +154,7 @@ const NavBar = () => {
: t('back')
}
>
-
+
)
}
@@ -201,14 +222,19 @@ const NavBar = () => {
{
- const { to, icon, label } = link
+ const { to, icon, label, onClick } = link
return (
{
- // if the last word start with '@' or '#' or 'P':
- const lastWord = text.split(' ').pop()
- if (
- lastWord.startsWith('@') ||
- lastWord.startsWith('#') ||
- lastWord.startsWith('!')
- ) {
+ // show the menu when the last word starts with a configured trigger
+ // character (e.g. '@', '#', '!', '*')
+ const lastWord = text.split(/\s+/).pop()
+ if (lastWord && suggestions?.[lastWord[0]]) {
setSuggestionTrigger(lastWord[0])
// last word without the first character:
setLastWord(lastWord.slice(1))
-
+ setSelectedSuggestionIndex(0)
setShowSuggestions(true)
} else {
setShowSuggestions(false)
@@ -121,6 +118,7 @@ const SmartTaskTitleInput = ({
const newCursorPosition =
cursorPosition - lastWord.length + suggestionValue.length + 1
+ setCursorPosition(newCursorPosition)
titleInputRef.current.setSelectionRange(
newCursorPosition,
newCursorPosition,
@@ -376,18 +374,10 @@ const SmartTaskTitleInput = ({
const suggestionValue = suggestions[suggestionTrigger].display
? suggestion[suggestions[suggestionTrigger].display]
: suggestion
- const newValue = `${value.slice(0, cursorPosition)}${suggestionValue}${value.slice(cursorPosition)}`
-
- onChange(newValue)
+ // Same insertion path as keyboard selection: replace the partial
+ // word typed after the trigger instead of inserting alongside it
titleInputRef?.current?.focus()
-
- setCursorPosition(cursorPosition + suggestion.length)
- titleInputRef.current.value = newValue
- titleInputRef.current.setSelectionRange(
- cursorPosition + suggestionValue.length,
- cursorPosition + suggestionValue.length,
- )
- setShowSuggestions(false)
+ selectSuggestionText(suggestionValue)
}}
onCreateSuggestion={name => {
selectSuggestionText(name)