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

View File

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

View File

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