feat: enhance search with fuzzy matching, word-based filtering, and answered/unanswered filters

This commit is contained in:
nmemmert committed 2026-01-16 08:16:27 -05:00
1 parent bbc13237cc
commit b013e972d2
1 file changed
+110 -29
+110 -29
View File
@@ -1,12 +1,13 @@
'use client'; 'use client';
import React, { useState } from 'react'; import React, { useState, useMemo } from 'react';
interface Prayer { interface Prayer {
id: string; id: string;
date: string; date: string;
text: string; text: string;
prayFor?: string; prayFor?: string;
answered?: boolean;
} }
interface PrayerListProps { interface PrayerListProps {
@@ -18,34 +19,60 @@ interface PrayerListProps {
export default function PrayerList({ prayers, onPrayerClick, onEdit }: PrayerListProps) { export default function PrayerList({ prayers, onPrayerClick, onEdit }: PrayerListProps) {
const [searchTerm, setSearchTerm] = useState(''); const [searchTerm, setSearchTerm] = useState('');
const [sortOrder, setSortOrder] = useState<'newest' | 'oldest'>('newest'); const [sortOrder, setSortOrder] = useState<'newest' | 'oldest'>('newest');
const [filterAnswered, setFilterAnswered] = useState<'all' | 'answered' | 'unanswered'>('all');
const filtered = prayers // Fuzzy search helper - finds if search term matches any part of text
.filter( const fuzzyMatch = (text: string, searchTerm: string): boolean => {
p => if (!searchTerm) return true;
p.text.toLowerCase().includes(searchTerm.toLowerCase()) || const searchLower = searchTerm.toLowerCase().trim();
p.prayFor?.toLowerCase().includes(searchTerm.toLowerCase()) const textLower = text.toLowerCase();
)
// Direct substring match (most relevant)
if (textLower.includes(searchLower)) return true;
// Split search into words and check if all words are found
const words = searchLower.split(/\s+/);
return words.every(word => textLower.includes(word));
};
const filtered = useMemo(() => {
return prayers
.filter(p => {
// Search filter
const matchesSearch = fuzzyMatch(p.text, searchTerm) ||
fuzzyMatch(p.prayFor || '', searchTerm);
if (!matchesSearch) return false;
// Answered filter
if (filterAnswered === 'answered' && !p.answered) return false;
if (filterAnswered === 'unanswered' && p.answered) return false;
return true;
})
.sort((a, b) => { .sort((a, b) => {
const comparison = new Date(b.date).getTime() - new Date(a.date).getTime(); const comparison = new Date(b.date).getTime() - new Date(a.date).getTime();
return sortOrder === 'newest' ? comparison : -comparison; return sortOrder === 'newest' ? comparison : -comparison;
}); });
}, [prayers, searchTerm, sortOrder, filterAnswered]);
const grouped = filtered.reduce((acc, prayer) => { const grouped = useMemo(() => {
return filtered.reduce((acc, prayer) => {
if (!acc[prayer.date]) acc[prayer.date] = []; if (!acc[prayer.date]) acc[prayer.date] = [];
acc[prayer.date].push(prayer); acc[prayer.date].push(prayer);
return acc; return acc;
}, {} as Record<string, Prayer[]>); }, {} as Record<string, Prayer[]>);
}, [filtered]);
const resultCount = filtered.length;
return ( return (
<div className="min-h-screen bg-gradient-to-b from-slate-50 to-slate-100 dark:from-slate-900 dark:to-slate-800 pt-20 px-4 pb-8"> <div className="min-h-screen bg-gradient-to-b from-slate-900 to-slate-800 pt-20 px-4 pb-8">
<div className="max-w-2xl mx-auto"> <div className="max-w-2xl mx-auto">
{/* Header */} {/* Header */}
<div className="text-center mb-8"> <div className="text-center mb-8">
<h1 className="text-4xl font-bold text-slate-900 dark:text-white mb-2"> <h1 className="text-4xl font-bold text-white mb-2">Past Prayers</h1>
Past Prayers <p className="text-slate-400">
</h1> {resultCount} {resultCount === 1 ? 'prayer' : 'prayers'} found
<p className="text-slate-600 dark:text-slate-400">
Browse and reflect on your prayer history
</p> </p>
</div> </div>
@@ -53,31 +80,67 @@ export default function PrayerList({ prayers, onPrayerClick, onEdit }: PrayerLis
<div className="space-y-4 mb-8"> <div className="space-y-4 mb-8">
<input <input
type="text" type="text"
placeholder="Search prayers..." placeholder="Search by prayer text or person..."
value={searchTerm} value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)} onChange={(e) => setSearchTerm(e.target.value)}
className="w-full px-4 py-3 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800 text-slate-900 dark:text-white placeholder-slate-400 dark:placeholder-slate-500 focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all" className="w-full px-4 py-3 rounded-lg border border-slate-600 bg-slate-800 text-white placeholder-slate-500 focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all"
/> />
{/* Sort Options */}
<div className="flex gap-2"> <div className="flex gap-2">
<button <button
onClick={() => setSortOrder('newest')} onClick={() => setSortOrder('newest')}
className={`flex-1 px-4 py-2 rounded-lg font-medium transition-colors ${ className={`flex-1 px-4 py-2 rounded-lg font-medium transition-colors ${
sortOrder === 'newest' sortOrder === 'newest'
? 'bg-blue-600 text-white' ? 'bg-blue-600 text-white'
: 'bg-slate-200 dark:bg-slate-700 text-slate-900 dark:text-white hover:bg-slate-300 dark:hover:bg-slate-600' : 'bg-slate-700 text-slate-300 hover:bg-slate-600'
}`} }`}
> >
Newest First 📅 Newest
</button> </button>
<button <button
onClick={() => setSortOrder('oldest')} onClick={() => setSortOrder('oldest')}
className={`flex-1 px-4 py-2 rounded-lg font-medium transition-colors ${ className={`flex-1 px-4 py-2 rounded-lg font-medium transition-colors ${
sortOrder === 'oldest' sortOrder === 'oldest'
? 'bg-blue-600 text-white' ? 'bg-blue-600 text-white'
: 'bg-slate-200 dark:bg-slate-700 text-slate-900 dark:text-white hover:bg-slate-300 dark:hover:bg-slate-600' : 'bg-slate-700 text-slate-300 hover:bg-slate-600'
}`} }`}
> >
Oldest First 📅 Oldest
</button>
</div>
{/* Answer Status Filter */}
<div className="flex gap-2">
<button
onClick={() => setFilterAnswered('all')}
className={`flex-1 px-4 py-2 rounded-lg font-medium transition-colors text-sm ${
filterAnswered === 'all'
? 'bg-blue-600 text-white'
: 'bg-slate-700 text-slate-300 hover:bg-slate-600'
}`}
>
All Prayers
</button>
<button
onClick={() => setFilterAnswered('answered')}
className={`flex-1 px-4 py-2 rounded-lg font-medium transition-colors text-sm ${
filterAnswered === 'answered'
? 'bg-green-600 text-white'
: 'bg-slate-700 text-slate-300 hover:bg-slate-600'
}`}
>
✅ Answered
</button>
<button
onClick={() => setFilterAnswered('unanswered')}
className={`flex-1 px-4 py-2 rounded-lg font-medium transition-colors text-sm ${
filterAnswered === 'unanswered'
? 'bg-orange-600 text-white'
: 'bg-slate-700 text-slate-300 hover:bg-slate-600'
}`}
>
⏳ Waiting
</button> </button>
</div> </div>
</div> </div>
@@ -86,9 +149,20 @@ export default function PrayerList({ prayers, onPrayerClick, onEdit }: PrayerLis
<div className="space-y-6"> <div className="space-y-6">
{filtered.length === 0 ? ( {filtered.length === 0 ? (
<div className="text-center py-12"> <div className="text-center py-12">
<p className="text-slate-600 dark:text-slate-400 text-lg"> <p className="text-slate-400 text-lg">
No prayers found. {searchTerm && 'Try adjusting your search.'} {searchTerm || filterAnswered !== 'all' ? '❌ No prayers match your filters.' : '📝 No prayers yet. Start praying!'}
</p> </p>
{searchTerm && (
<button
onClick={() => {
setSearchTerm('');
setFilterAnswered('all');
}}
className="mt-4 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
Clear Filters
</button>
)}
</div> </div>
) : ( ) : (
Object.keys(grouped) Object.keys(grouped)
@@ -96,7 +170,7 @@ export default function PrayerList({ prayers, onPrayerClick, onEdit }: PrayerLis
.reverse() .reverse()
.map(date => ( .map(date => (
<div key={date}> <div key={date}>
<h2 className="text-sm font-semibold text-slate-500 dark:text-slate-400 mb-3 uppercase tracking-wider"> <h2 className="text-sm font-semibold text-slate-400 mb-3 uppercase tracking-wider">
{new Date(date).toLocaleDateString('en-US', { {new Date(date).toLocaleDateString('en-US', {
weekday: 'long', weekday: 'long',
month: 'short', month: 'short',
@@ -112,19 +186,26 @@ export default function PrayerList({ prayers, onPrayerClick, onEdit }: PrayerLis
{grouped[date].map(prayer => ( {grouped[date].map(prayer => (
<div <div
key={prayer.id} key={prayer.id}
className="bg-white dark:bg-slate-800 rounded-lg p-6 shadow hover:shadow-lg transition-all cursor-pointer border border-slate-200 dark:border-slate-700" className="bg-slate-800 rounded-lg p-6 shadow hover:shadow-lg transition-all cursor-pointer border border-slate-700 hover:border-blue-500"
onClick={() => onPrayerClick(prayer)} onClick={() => onPrayerClick(prayer)}
> >
<div className="flex items-start gap-3">
{prayer.answered && (
<span className="text-2xl">✅</span>
)}
<div className="flex-1 min-w-0">
{prayer.prayFor && ( {prayer.prayFor && (
<p className="text-sm font-medium text-blue-600 dark:text-blue-400 mb-2"> <p className="text-sm font-medium text-blue-400 mb-2">
Praying for: {prayer.prayFor} 🙏 {prayer.prayFor}
</p> </p>
)} )}
<p className="text-slate-900 dark:text-white line-clamp-3"> <p className="text-slate-200 line-clamp-3">
{prayer.text} {prayer.text}
</p> </p>
</div>
</div>
<div className="mt-4 flex justify-between items-center"> <div className="mt-4 flex justify-between items-center">
<span className="text-xs text-slate-500 dark:text-slate-400"> <span className="text-xs text-slate-500">
{new Date(date).toLocaleTimeString('en-US', { {new Date(date).toLocaleTimeString('en-US', {
hour: 'numeric', hour: 'numeric',
minute: '2-digit', minute: '2-digit',
@@ -135,7 +216,7 @@ export default function PrayerList({ prayers, onPrayerClick, onEdit }: PrayerLis
e.stopPropagation(); e.stopPropagation();
onEdit(prayer); onEdit(prayer);
}} }}
className="text-xs px-3 py-1 bg-blue-100 dark:bg-blue-900 text-blue-600 dark:text-blue-400 rounded hover:bg-blue-200 dark:hover:bg-blue-800 transition-colors" className="text-xs px-3 py-1 bg-blue-600 text-white rounded hover:bg-blue-700 transition-colors"
> >
Edit Edit
</button> </button>