diff --git a/src/utils/__tests__/fuzzyMatch.spec.ts b/src/utils/__tests__/fuzzyMatch.spec.ts new file mode 100644 index 0000000..be87435 --- /dev/null +++ b/src/utils/__tests__/fuzzyMatch.spec.ts @@ -0,0 +1,24 @@ +import { describe, it, expect } from 'vitest' +import { fuzzyMatch } from '../fuzzyMatch' + +describe('fuzzyMatch', () => { + it('matches an empty query against anything', () => { + expect(fuzzyMatch('', 'Groceries')).toBe(true) + expect(fuzzyMatch(' ', 'Groceries')).toBe(true) + }) + + it('matches an exact substring, case-insensitively', () => { + expect(fuzzyMatch('milk', 'Buy milk and eggs')).toBe(true) + expect(fuzzyMatch('MILK', 'buy milk and eggs')).toBe(true) + }) + + it('tolerates a couple of off characters', () => { + expect(fuzzyMatch('mikl', 'Buy milk and eggs')).toBe(true) + expect(fuzzyMatch('grocries', 'Groceries')).toBe(true) + }) + + it('does not match unrelated text', () => { + expect(fuzzyMatch('xyz', 'Groceries')).toBe(false) + expect(fuzzyMatch('banana', 'Groceries')).toBe(false) + }) +}) diff --git a/src/utils/fuzzyMatch.ts b/src/utils/fuzzyMatch.ts new file mode 100644 index 0000000..0015370 --- /dev/null +++ b/src/utils/fuzzyMatch.ts @@ -0,0 +1,45 @@ +function levenshtein(a: string, b: string): number { + const rows = a.length + 1 + const cols = b.length + 1 + let prev: number[] = Array.from({ length: cols }, (_, j) => j) + + for (let i = 1; i < rows; i++) { + const curr: number[] = [i] + for (let j = 1; j < cols; j++) { + const cost = a[i - 1] === b[j - 1] ? 0 : 1 + curr.push(Math.min((curr[j - 1] ?? 0) + 1, (prev[j] ?? 0) + 1, (prev[j - 1] ?? 0) + cost)) + } + prev = curr + } + + return prev[cols - 1] ?? 0 +} + +function maxDistanceFor(queryLength: number): number { + if (queryLength <= 4) return 1 + return 2 +} + +/** + * Cheap, local fuzzy substring match: true if `text` contains a run of + * characters within edit-distance of `query` (tolerating ~1-2 typos). + */ +export function fuzzyMatch(query: string, text: string): boolean { + const q = query.trim().toLowerCase() + if (!q) return true + + const t = text.toLowerCase() + if (t.includes(q)) return true + + const maxDistance = maxDistanceFor(q.length) + const minLen = Math.max(1, q.length - maxDistance) + const maxLen = q.length + maxDistance + + for (let len = minLen; len <= maxLen; len++) { + for (let start = 0; start + len <= t.length; start++) { + if (levenshtein(q, t.substring(start, start + len)) <= maxDistance) return true + } + } + + return false +} diff --git a/src/views/ListDetailView.vue b/src/views/ListDetailView.vue index edfd859..f7f8b05 100644 --- a/src/views/ListDetailView.vue +++ b/src/views/ListDetailView.vue @@ -3,6 +3,7 @@ import { ref, computed, onMounted, watch } from 'vue' import { useRouter } from 'vue-router' import { useListsStore } from '@/stores/lists' import type { LocalListItem } from '@/database/db' +import { fuzzyMatch } from '@/utils/fuzzyMatch' import ListItemRow from '@/components/ListItemRow.vue' import DeleteListModal from '@/components/DeleteListModal.vue' import { useDismissableMenu } from '@/composables/useDismissableMenu' @@ -12,7 +13,8 @@ const props = defineProps<{ id: string }>() const router = useRouter() const listsStore = useListsStore() -const newItemTitle = ref('') +// Doubles as the "add item" text box and the live filter query on the list below. +const itemInput = ref('') const isAddingItem = ref(false) const itemError = ref('') const showDeleteModal = ref(false) @@ -44,11 +46,21 @@ function byModifiedDesc(a: LocalListItem, b: LocalListItem) { return (b.modified_at ?? '').localeCompare(a.modified_at ?? '') } +// Filtering is local and fuzzy only, for display purposes — it never touches +// the server or the sync queue. +const filterQuery = computed(() => itemInput.value.trim()) + +const filteredItems = computed(() => { + const query = filterQuery.value + if (!query) return items.value + return items.value.filter((item) => fuzzyMatch(query, item.title)) +}) + const pendingItems = computed(() => - items.value.filter((item) => !item.is_completed).sort(byModifiedDesc), + filteredItems.value.filter((item) => !item.is_completed).sort(byModifiedDesc), ) const completedItems = computed(() => - items.value.filter((item) => item.is_completed).sort(byModifiedDesc), + filteredItems.value.filter((item) => item.is_completed).sort(byModifiedDesc), ) function handleOpenDelete() { @@ -72,14 +84,14 @@ async function handleConfirmDelete() { } async function handleAddItem() { - const title = newItemTitle.value.trim() + const title = filterQuery.value if (!title) return itemError.value = '' isAddingItem.value = true try { await listsStore.createListItem(props.id, title) - newItemTitle.value = '' + itemInput.value = '' } catch (err) { itemError.value = err instanceof Error ? err.message : 'Failed to add item' } finally { @@ -146,7 +158,7 @@ async function handleAddItem() {