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."
|
||||
},
|
||||
"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."
|
||||
}
|
||||
|
||||
@@ -78,11 +78,16 @@ const scheduleNotificationFromTemplate = (
|
||||
const now = new Date()
|
||||
const time = getTimeFromTemplate(template, dueDate)
|
||||
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) {
|
||||
notifications.push({
|
||||
title,
|
||||
body: `${body} at ${time.toLocaleTimeString()}`,
|
||||
body,
|
||||
id: notificationId,
|
||||
allowWhileIdle: true,
|
||||
schedule: {
|
||||
@@ -96,91 +101,50 @@ const scheduleNotificationFromTemplate = (
|
||||
}
|
||||
}
|
||||
|
||||
const getNotificationText = (choreName, template = {}) => {
|
||||
// Determine notification type based on template value
|
||||
const getNotificationType = () => {
|
||||
if (!template || template.value === undefined) {
|
||||
return 'due'
|
||||
}
|
||||
const getNotificationText = (
|
||||
choreName,
|
||||
template = {},
|
||||
dueDate,
|
||||
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) {
|
||||
return 'reminder'
|
||||
} else if (template.value === 0) {
|
||||
return 'due'
|
||||
} else {
|
||||
return 'overdue'
|
||||
}
|
||||
let dueTime
|
||||
if (dayDifference === 0) {
|
||||
dueTime = `today at ${time}`
|
||||
} else if (dayDifference === 1) {
|
||||
dueTime = `tomorrow at ${time}`
|
||||
} else if (dayDifference === -1) {
|
||||
dueTime = `yesterday at ${time}`
|
||||
} else {
|
||||
const date = dueDate.toLocaleDateString([], {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
})
|
||||
dueTime = `${date} at ${time}`
|
||||
}
|
||||
|
||||
const notificationType = getNotificationType()
|
||||
|
||||
// Truncate chore name if too long for better readability
|
||||
const maxChoreNameLength = 25
|
||||
const truncatedName =
|
||||
choreName.length > maxChoreNameLength
|
||||
? `${choreName.substring(0, maxChoreNameLength)}...`
|
||||
: 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`
|
||||
let body
|
||||
if (template.value < 0) {
|
||||
body = `Due ${dueTime}`
|
||||
} else if (template.value > 0) {
|
||||
body = `Overdue · Was due ${dueTime}`
|
||||
} else {
|
||||
body = 'Due now'
|
||||
}
|
||||
|
||||
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 {
|
||||
title: messageTemplate.title,
|
||||
body: messageTemplate.body,
|
||||
title: choreName,
|
||||
body,
|
||||
}
|
||||
}
|
||||
const cancelPendingNotifications = async () => {
|
||||
|
||||
@@ -7,10 +7,12 @@ import {
|
||||
CircularProgress,
|
||||
Container,
|
||||
IconButton,
|
||||
Input,
|
||||
Stack,
|
||||
Typography,
|
||||
} 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 LabelModal from '../Modals/Inputs/LabelModal'
|
||||
|
||||
@@ -22,7 +24,14 @@ import {
|
||||
TrailingActions,
|
||||
} from '@meauxt/react-swipeable-list'
|
||||
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 { useQueryClient } from '@tanstack/react-query'
|
||||
import { useUserProfile } from '../../queries/UserQueries'
|
||||
@@ -162,6 +171,36 @@ const LabelView = () => {
|
||||
const queryClient = useQueryClient()
|
||||
const [confirmationModel, setConfirmationModel] = useState({})
|
||||
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 = () => {
|
||||
setCurrentLabel(null)
|
||||
@@ -251,6 +290,36 @@ const LabelView = () => {
|
||||
</Typography>
|
||||
</Stack>
|
||||
</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
|
||||
sx={{
|
||||
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}>
|
||||
{userLabels.map(label => (
|
||||
{filteredLabels.map(label => (
|
||||
<SwipeableListItem
|
||||
key={label.id}
|
||||
swipeActionOpen={showMoreInfoId === label.id ? 'trailing' : null}
|
||||
|
||||
Reference in New Issue
Block a user