enhance label search functionality with Fuse.js integration and improved UI

This commit is contained in:
Mo Tarbin
2026-08-15 00:45:51 -04:00
parent ddb726ad1f
commit 7729917611
3 changed files with 138 additions and 84 deletions

View File

@@ -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."
}

View File

@@ -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 () => {

View File

@@ -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}