enhance label search functionality with Fuse.js integration and improved UI
This commit is contained in:
@@ -5,5 +5,11 @@
|
|||||||
"message": "Are you sure you want to delete this label? This will remove the label from all tasks."
|
"message": "Are you sure you want to delete this label? This will remove the label from all tasks."
|
||||||
},
|
},
|
||||||
"loadError": "Failed to load labels. Please try again.",
|
"loadError": "Failed to load labels. Please try again.",
|
||||||
|
"search": {
|
||||||
|
"placeholder": "Search labels",
|
||||||
|
"noResultsTitle": "No labels match",
|
||||||
|
"noResultsDescription": "No label matches \"{{searchTerm}}\".",
|
||||||
|
"clear": "Clear search"
|
||||||
|
},
|
||||||
"blurb": "Manage your labels and organize your tasks effectively. Labels will be automatically shared with your circle if they are used on a shared task."
|
"blurb": "Manage your labels and organize your tasks effectively. Labels will be automatically shared with your circle if they are used on a shared task."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -78,11 +78,16 @@ const scheduleNotificationFromTemplate = (
|
|||||||
const now = new Date()
|
const now = new Date()
|
||||||
const time = getTimeFromTemplate(template, dueDate)
|
const time = getTimeFromTemplate(template, dueDate)
|
||||||
const notificationId = getIdFromTemplate(chore.id, template)
|
const notificationId = getIdFromTemplate(chore.id, template)
|
||||||
const { title, body } = getNotificationText(chore.name, template)
|
const { title, body } = getNotificationText(
|
||||||
|
chore.name,
|
||||||
|
template,
|
||||||
|
dueDate,
|
||||||
|
time,
|
||||||
|
)
|
||||||
if (time > now) {
|
if (time > now) {
|
||||||
notifications.push({
|
notifications.push({
|
||||||
title,
|
title,
|
||||||
body: `${body} at ${time.toLocaleTimeString()}`,
|
body,
|
||||||
id: notificationId,
|
id: notificationId,
|
||||||
allowWhileIdle: true,
|
allowWhileIdle: true,
|
||||||
schedule: {
|
schedule: {
|
||||||
@@ -96,91 +101,50 @@ const scheduleNotificationFromTemplate = (
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const getNotificationText = (choreName, template = {}) => {
|
const getNotificationText = (
|
||||||
// Determine notification type based on template value
|
choreName,
|
||||||
const getNotificationType = () => {
|
template = {},
|
||||||
if (!template || template.value === undefined) {
|
dueDate,
|
||||||
return 'due'
|
notificationTime,
|
||||||
}
|
) => {
|
||||||
|
const startOfDay = date =>
|
||||||
|
new Date(date.getFullYear(), date.getMonth(), date.getDate())
|
||||||
|
const dayDifference = Math.round(
|
||||||
|
(startOfDay(dueDate) - startOfDay(notificationTime)) /
|
||||||
|
(24 * 60 * 60 * 1000),
|
||||||
|
)
|
||||||
|
const time = dueDate.toLocaleTimeString([], {
|
||||||
|
hour: 'numeric',
|
||||||
|
minute: '2-digit',
|
||||||
|
})
|
||||||
|
|
||||||
if (template.value < 0) {
|
let dueTime
|
||||||
return 'reminder'
|
if (dayDifference === 0) {
|
||||||
} else if (template.value === 0) {
|
dueTime = `today at ${time}`
|
||||||
return 'due'
|
} else if (dayDifference === 1) {
|
||||||
} else {
|
dueTime = `tomorrow at ${time}`
|
||||||
return 'overdue'
|
} else if (dayDifference === -1) {
|
||||||
}
|
dueTime = `yesterday at ${time}`
|
||||||
|
} else {
|
||||||
|
const date = dueDate.toLocaleDateString([], {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
})
|
||||||
|
dueTime = `${date} at ${time}`
|
||||||
}
|
}
|
||||||
|
|
||||||
const notificationType = getNotificationType()
|
let body
|
||||||
|
if (template.value < 0) {
|
||||||
// Truncate chore name if too long for better readability
|
body = `Due ${dueTime}`
|
||||||
const maxChoreNameLength = 25
|
} else if (template.value > 0) {
|
||||||
const truncatedName =
|
body = `Overdue · Was due ${dueTime}`
|
||||||
choreName.length > maxChoreNameLength
|
} else {
|
||||||
? `${choreName.substring(0, maxChoreNameLength)}...`
|
body = 'Due now'
|
||||||
: choreName
|
|
||||||
|
|
||||||
// Generate time-based descriptive text
|
|
||||||
const getTimeDescription = () => {
|
|
||||||
if (!template || !template.value || !template.unit) {
|
|
||||||
return 'soon'
|
|
||||||
}
|
|
||||||
|
|
||||||
const { value, unit } = template
|
|
||||||
const absValue = Math.abs(value)
|
|
||||||
|
|
||||||
switch (unit) {
|
|
||||||
case 'm':
|
|
||||||
if (absValue === 1) return value < 0 ? 'in 1 minute' : '1 minute ago'
|
|
||||||
if (absValue < 60)
|
|
||||||
return value < 0
|
|
||||||
? `in ${absValue} minutes`
|
|
||||||
: `${absValue} minutes ago`
|
|
||||||
break
|
|
||||||
case 'h':
|
|
||||||
if (absValue === 1) return value < 0 ? 'in 1 hour' : '1 hour ago'
|
|
||||||
if (absValue < 24)
|
|
||||||
return value < 0 ? `in ${absValue} hours` : `${absValue} hours ago`
|
|
||||||
break
|
|
||||||
case 'd':
|
|
||||||
if (absValue === 1) return value < 0 ? 'tomorrow' : 'yesterday'
|
|
||||||
if (absValue === 7) return value < 0 ? 'next week' : 'last week'
|
|
||||||
if (absValue < 7)
|
|
||||||
return value < 0 ? `in ${absValue} days` : `${absValue} days ago`
|
|
||||||
if (absValue < 30) {
|
|
||||||
const weeks = Math.round(absValue / 7)
|
|
||||||
return value < 0 ? `in ${weeks} weeks` : `${weeks} weeks ago`
|
|
||||||
}
|
|
||||||
break
|
|
||||||
default:
|
|
||||||
return value < 0 ? `in ${absValue} ${unit}` : `${absValue} ${unit} ago`
|
|
||||||
}
|
|
||||||
|
|
||||||
return value < 0 ? `in ${absValue} ${unit}` : `${absValue} ${unit} ago`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const messages = {
|
|
||||||
reminder: {
|
|
||||||
title: `📋 ${truncatedName}`,
|
|
||||||
body: `Reminder: Due ${getTimeDescription()}`,
|
|
||||||
},
|
|
||||||
due: {
|
|
||||||
title: `🔔 ${truncatedName}`,
|
|
||||||
body: 'Due now - Time to get started!',
|
|
||||||
},
|
|
||||||
overdue: {
|
|
||||||
title: `❗ ${truncatedName}`,
|
|
||||||
body: `Overdue ${getTimeDescription()} - Complete when you can`,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback to due if type not found
|
|
||||||
const messageTemplate = messages[notificationType] || messages.due
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title: messageTemplate.title,
|
title: choreName,
|
||||||
body: messageTemplate.body,
|
body,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const cancelPendingNotifications = async () => {
|
const cancelPendingNotifications = async () => {
|
||||||
|
|||||||
@@ -7,10 +7,12 @@ import {
|
|||||||
CircularProgress,
|
CircularProgress,
|
||||||
Container,
|
Container,
|
||||||
IconButton,
|
IconButton,
|
||||||
|
Input,
|
||||||
Stack,
|
Stack,
|
||||||
Typography,
|
Typography,
|
||||||
} from '@mui/joy'
|
} from '@mui/joy'
|
||||||
import { useEffect, useState } from 'react'
|
import Fuse from 'fuse.js'
|
||||||
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import LabelModal from '../Modals/Inputs/LabelModal'
|
import LabelModal from '../Modals/Inputs/LabelModal'
|
||||||
|
|
||||||
@@ -22,7 +24,14 @@ import {
|
|||||||
TrailingActions,
|
TrailingActions,
|
||||||
} from '@meauxt/react-swipeable-list'
|
} from '@meauxt/react-swipeable-list'
|
||||||
import '@meauxt/react-swipeable-list/dist/styles.css'
|
import '@meauxt/react-swipeable-list/dist/styles.css'
|
||||||
import { Add, MoreVert, Style } from '@mui/icons-material'
|
import {
|
||||||
|
Add,
|
||||||
|
Close,
|
||||||
|
MoreVert,
|
||||||
|
Search,
|
||||||
|
SearchOff,
|
||||||
|
Style,
|
||||||
|
} from '@mui/icons-material'
|
||||||
import EmptyState from '../../components/common/EmptyState'
|
import EmptyState from '../../components/common/EmptyState'
|
||||||
import { useQueryClient } from '@tanstack/react-query'
|
import { useQueryClient } from '@tanstack/react-query'
|
||||||
import { useUserProfile } from '../../queries/UserQueries'
|
import { useUserProfile } from '../../queries/UserQueries'
|
||||||
@@ -162,6 +171,36 @@ const LabelView = () => {
|
|||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const [confirmationModel, setConfirmationModel] = useState({})
|
const [confirmationModel, setConfirmationModel] = useState({})
|
||||||
const [showMoreInfoId, setShowMoreInfoId] = useState(null)
|
const [showMoreInfoId, setShowMoreInfoId] = useState(null)
|
||||||
|
const [searchTerm, setSearchTerm] = useState('')
|
||||||
|
const searchInputRef = useRef(null)
|
||||||
|
|
||||||
|
const fuse = useMemo(
|
||||||
|
() =>
|
||||||
|
new Fuse(userLabels, {
|
||||||
|
keys: ['name'],
|
||||||
|
includeScore: true,
|
||||||
|
isCaseSensitive: false,
|
||||||
|
findAllMatches: true,
|
||||||
|
}),
|
||||||
|
[userLabels],
|
||||||
|
)
|
||||||
|
|
||||||
|
const filteredLabels = useMemo(() => {
|
||||||
|
if (!searchTerm) {
|
||||||
|
return userLabels
|
||||||
|
}
|
||||||
|
return fuse.search(searchTerm).map(result => result.item)
|
||||||
|
}, [fuse, searchTerm, userLabels])
|
||||||
|
|
||||||
|
const handleSearchChange = e => {
|
||||||
|
setSearchTerm(e.target.value.toLowerCase())
|
||||||
|
setShowMoreInfoId(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSearchClose = () => {
|
||||||
|
setSearchTerm('')
|
||||||
|
searchInputRef.current?.blur()
|
||||||
|
}
|
||||||
|
|
||||||
const handleAddLabel = () => {
|
const handleAddLabel = () => {
|
||||||
setCurrentLabel(null)
|
setCurrentLabel(null)
|
||||||
@@ -251,6 +290,36 @@ const LabelView = () => {
|
|||||||
</Typography>
|
</Typography>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Box>
|
</Box>
|
||||||
|
{userLabels.length > 0 && (
|
||||||
|
<Box sx={{ px: 2, mb: 2 }}>
|
||||||
|
<Input
|
||||||
|
slotProps={{ input: { ref: searchInputRef } }}
|
||||||
|
placeholder={t('search.placeholder')}
|
||||||
|
value={searchTerm}
|
||||||
|
fullWidth
|
||||||
|
sx={{
|
||||||
|
borderRadius: 24,
|
||||||
|
height: 24,
|
||||||
|
borderColor: 'text.disabled',
|
||||||
|
padding: 1,
|
||||||
|
}}
|
||||||
|
onChange={handleSearchChange}
|
||||||
|
startDecorator={<Search />}
|
||||||
|
endDecorator={
|
||||||
|
searchTerm && (
|
||||||
|
<IconButton
|
||||||
|
variant='plain'
|
||||||
|
size='sm'
|
||||||
|
onClick={handleSearchClose}
|
||||||
|
sx={{ borderRadius: '50%' }}
|
||||||
|
>
|
||||||
|
<Close />
|
||||||
|
</IconButton>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
@@ -269,8 +338,23 @@ const LabelView = () => {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{userLabels.length > 0 && filteredLabels.length === 0 && (
|
||||||
|
<EmptyState
|
||||||
|
variant='no-results'
|
||||||
|
fullHeight
|
||||||
|
icon={<SearchOff />}
|
||||||
|
title={t('search.noResultsTitle')}
|
||||||
|
description={t('search.noResultsDescription', {
|
||||||
|
searchTerm,
|
||||||
|
})}
|
||||||
|
primaryAction={{
|
||||||
|
label: t('search.clear'),
|
||||||
|
onClick: handleSearchClose,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<SwipeableList type={ListType.IOS} fullSwipe={false}>
|
<SwipeableList type={ListType.IOS} fullSwipe={false}>
|
||||||
{userLabels.map(label => (
|
{filteredLabels.map(label => (
|
||||||
<SwipeableListItem
|
<SwipeableListItem
|
||||||
key={label.id}
|
key={label.id}
|
||||||
swipeActionOpen={showMoreInfoId === label.id ? 'trailing' : null}
|
swipeActionOpen={showMoreInfoId === label.id ? 'trailing' : null}
|
||||||
|
|||||||
Reference in New Issue
Block a user