diff --git a/src/components/AddExpense/AddExpensePage.tsx b/src/components/AddExpense/AddExpensePage.tsx index 28fe92b..6efa7e3 100644 --- a/src/components/AddExpense/AddExpensePage.tsx +++ b/src/components/AddExpense/AddExpensePage.tsx @@ -27,98 +27,13 @@ import UploadFile from './UploadFile'; import { CategoryIcons } from '../ui/categoryIcons'; import Link from 'next/link'; import { CURRENCIES } from '~/lib/currency'; +import { CATEGORIES } from '~/lib/categories'; import { env } from '~/env'; import { AMOUNT_TOO_LARGE_MESSAGE, isAmountWithinLimit } from '~/utils/numbers'; import { AdSlot } from '../Ads/AdSlot'; import { useRecentCurrencies } from '~/hooks/useRecentCurrencies'; import { SPLIT_WAITING_MESSAGE, getDefaultSplitNote } from '~/utils/defaultSplit'; -const categories = { - entertainment: { - name: 'Entertainment', - items: [ - { - games: 'Games', - movies: 'Movies', - music: 'Music', - sports: 'Sports', - other: 'Entertainment', - }, - ], - }, - food: { - name: 'Food & Drinks', - items: [ - { - diningOut: 'Dining Out', - groceries: 'Groceries', - liquor: 'Liquor', - other: 'Food & Drinks', - }, - ], - }, - home: { - name: 'Home', - items: [ - { - electronics: 'Electronics', - furniture: 'Furniture', - supplies: 'Supplies', - maintenance: 'Maintenance', - mortgage: 'Mortgage', - pets: 'Pets', - rent: 'Rent', - services: 'Services', - other: 'Home', - }, - ], - }, - life: { - name: 'Life', - items: [ - { - childcare: 'Childcare', - clothing: 'Clothing', - education: 'Education', - gifts: 'Gifts', - medical: 'Medical', - taxes: 'Taxes', - other: 'Life', - }, - ], - }, - travel: { - name: 'Travel', - items: [ - { - bus: 'Bus', - train: 'Train', - car: 'Car', - fuel: 'Fuel', - parking: 'Parking', - plane: 'Plane', - taxi: 'Taxi', - other: 'Travel', - }, - ], - }, - utilities: { - name: 'Utilities', - items: [ - { - cleaning: 'Cleaning', - electricity: 'Electricity', - gas: 'Gas', - internet: 'Internet', - trash: 'Trash', - phone: 'Phone', - water: 'Water', - other: 'Utilities', - }, - ], - }, -}; - export const AddOrEditExpensePage: React.FC<{ isStorageConfigured: boolean; enableSendingInvites: boolean; @@ -365,7 +280,7 @@ export const AddOrEditExpensePage: React.FC<{ shouldCloseOnAction >
- {Object.entries(categories).map(([categoryName, categoryDetails]) => { + {Object.entries(CATEGORIES).map(([categoryName, categoryDetails]) => { return (

{categoryDetails.name}

diff --git a/src/components/Expense/ExpenseSearch.tsx b/src/components/Expense/ExpenseSearch.tsx new file mode 100644 index 0000000..aa20d2f --- /dev/null +++ b/src/components/Expense/ExpenseSearch.tsx @@ -0,0 +1,89 @@ +import React, { useState } from 'react'; +import { SearchIcon, X } from 'lucide-react'; +import { cn } from '~/lib/utils'; +import { Input } from '~/components/ui/input'; +import { Button } from '~/components/ui/button'; +import { + type ExpenseFilter, + type SearchableExpense, + filterExpenses, + getFilterOptions, +} from '~/utils/expenseSearch'; + +/** Search and category filter state for an expense list, kept on the page while it is open. */ +export const useExpenseSearch = ( + expenses: T[] | undefined, + getOtherText?: (expense: T) => (string | null | undefined)[], +) => { + const [filter, setFilter] = useState({ query: '', category: null }); + + return { + filter, + setFilter, + options: getFilterOptions(expenses ?? []), + filtered: expenses ? filterExpenses(expenses, filter, getOtherText) : undefined, + isFiltering: '' !== filter.query.trim() || null !== filter.category, + }; +}; + +export const ExpenseSearch: React.FC<{ + filter: ExpenseFilter; + onChange: (filter: ExpenseFilter) => void; + options: { key: string; label: string }[]; + className?: string; +}> = ({ filter, onChange, options, className }) => ( +
+
+ + onChange({ ...filter, query: e.target.value })} + className="h-9 pl-9 pr-9 text-base [&::-webkit-search-cancel-button]:hidden" + /> + {filter.query ? ( + + ) : null} +
+ {1 < options.length ? ( +
+ {options.map((option) => { + const isSelected = option.key === filter.category; + return ( + + ); + })} +
+ ) : null} +
+); + +export const NoMatchingExpenses: React.FC<{ onClear: () => void }> = ({ onClear }) => ( +
+ No expenses match your search + +
+); diff --git a/src/lib/categories.ts b/src/lib/categories.ts new file mode 100644 index 0000000..7946b86 --- /dev/null +++ b/src/lib/categories.ts @@ -0,0 +1,101 @@ +/** Expense categories as picked when adding an expense. `other` stores the group key itself. */ +export const CATEGORIES = { + entertainment: { + name: 'Entertainment', + items: [ + { + games: 'Games', + movies: 'Movies', + music: 'Music', + sports: 'Sports', + other: 'Entertainment', + }, + ], + }, + food: { + name: 'Food & Drinks', + items: [ + { + diningOut: 'Dining Out', + groceries: 'Groceries', + liquor: 'Liquor', + other: 'Food & Drinks', + }, + ], + }, + home: { + name: 'Home', + items: [ + { + electronics: 'Electronics', + furniture: 'Furniture', + supplies: 'Supplies', + maintenance: 'Maintenance', + mortgage: 'Mortgage', + pets: 'Pets', + rent: 'Rent', + services: 'Services', + other: 'Home', + }, + ], + }, + life: { + name: 'Life', + items: [ + { + childcare: 'Childcare', + clothing: 'Clothing', + education: 'Education', + gifts: 'Gifts', + medical: 'Medical', + taxes: 'Taxes', + other: 'Life', + }, + ], + }, + travel: { + name: 'Travel', + items: [ + { + bus: 'Bus', + train: 'Train', + car: 'Car', + fuel: 'Fuel', + parking: 'Parking', + plane: 'Plane', + taxi: 'Taxi', + other: 'Travel', + }, + ], + }, + utilities: { + name: 'Utilities', + items: [ + { + cleaning: 'Cleaning', + electricity: 'Electricity', + gas: 'Gas', + internet: 'Internet', + trash: 'Trash', + phone: 'Phone', + water: 'Water', + other: 'Utilities', + }, + ], + }, +}; + +const CATEGORY_LABELS: Record = { + general: 'General', + ...Object.fromEntries( + Object.entries(CATEGORIES).flatMap(([categoryName, categoryDetails]) => [ + [categoryName, categoryDetails.name], + ...categoryDetails.items.flatMap((item) => + Object.entries(item).filter(([key]) => 'other' !== key), + ), + ]), + ), +}; + +/** The name shown for a stored category, e.g. "diningOut" is "Dining Out". */ +export const getCategoryLabel = (category: string) => CATEGORY_LABELS[category] ?? category; diff --git a/src/pages/balances/[friendId].tsx b/src/pages/balances/[friendId].tsx index b6e81f0..6e9ff1d 100644 --- a/src/pages/balances/[friendId].tsx +++ b/src/pages/balances/[friendId].tsx @@ -24,6 +24,11 @@ import { DeleteFriend } from '~/components/Friend/DeleteFriend'; import { Export } from '~/components/Friend/Export'; import { DateColumn, MonthHeader } from '~/components/Expense/ExpenseListDate'; import { RepeatIcon } from '~/components/Expense/RepeatIcon'; +import { + ExpenseSearch, + NoMatchingExpenses, + useExpenseSearch, +} from '~/components/Expense/ExpenseSearch'; import { isNewMonth } from '~/utils/dates'; const FriendPage: NextPageWithUser = ({ user }) => { @@ -48,6 +53,10 @@ const FriendPage: NextPageWithUser = ({ user }) => { // The export keeps every expense; the list skips those where neither owes the other anything. const listedExpenses = expenses.data?.filter((e) => hasShareWithFriend(e, user.id, _friendId)); + const expenseSearch = useExpenseSearch(listedExpenses, (e) => [ + e.paidBy === user.id ? 'You' : friendQuery.data?.name, + e.group?.name, + ]); const youLent = balances.data?.filter((b) => b.amount > 0); const youOwe = balances.data?.filter((b) => b.amount < 0); @@ -161,7 +170,20 @@ const FriendPage: NextPageWithUser = ({ user }) => {
- {listedExpenses?.map((e, index, expenseList) => { + {listedExpenses?.length ? ( + + ) : null} + {expenseSearch.isFiltering && !expenseSearch.filtered?.length ? ( + expenseSearch.setFilter({ query: '', category: null })} + /> + ) : null} + {expenseSearch.filtered?.map((e, index, expenseList) => { const youPaid = e.paidBy === user.id; const yourShare = getNetShareLabel(getNetWithFriend(e, user.id, _friendId)); const isSettlement = e.splitType === SplitType.SETTLEMENT; diff --git a/src/pages/groups/[groupId].tsx b/src/pages/groups/[groupId].tsx index d92c31c..76b1607 100644 --- a/src/pages/groups/[groupId].tsx +++ b/src/pages/groups/[groupId].tsx @@ -48,6 +48,11 @@ import { GroupSettleUp } from '~/components/group/GroupSettleUp'; import { GroupDefaultSplit } from '~/components/group/GroupDefaultSplit'; import { GroupRecurringRules } from '~/components/Expense/RecurringRule'; import { RepeatIcon } from '~/components/Expense/RepeatIcon'; +import { + ExpenseSearch, + NoMatchingExpenses, + useExpenseSearch, +} from '~/components/Expense/ExpenseSearch'; const BalancePage: NextPageWithUser<{ enableSendingInvites: boolean; @@ -61,6 +66,11 @@ const BalancePage: NextPageWithUser<{ const deleteGroupMutation = api.group.delete.useMutation(); const leaveGroupMutation = api.group.leaveGroup.useMutation(); + const expenseSearch = useExpenseSearch(expensesQuery.data, (e) => [ + e.paidByUser.name ?? e.paidByUser.email, + ...e.expenseParticipants.map((p) => p.user.name ?? p.user.email), + ]); + const [isInviteCopied, setIsInviteCopied] = useState(false); const [showDeleteTrigger, setShowDeleteTrigger] = useState(false); const [showLeaveTrigger, setShowLeaveTrigger] = useState(false); @@ -389,7 +399,20 @@ const BalancePage: NextPageWithUser<{
)} - {expensesQuery.data?.map((e, index, expenses) => { + {expensesQuery.data?.length ? ( + + ) : null} + {expenseSearch.isFiltering && !expenseSearch.filtered?.length ? ( + expenseSearch.setFilter({ query: '', category: null })} + /> + ) : null} + {expenseSearch.filtered?.map((e, index, expenses) => { const youPaid = e.paidBy === user.id; const isSettlement = e.splitType === SplitType.SETTLEMENT; const receiver = isSettlement diff --git a/src/tests/expenseSearch.test.ts b/src/tests/expenseSearch.test.ts new file mode 100644 index 0000000..3ee1f76 --- /dev/null +++ b/src/tests/expenseSearch.test.ts @@ -0,0 +1,89 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { SplitType } from '@prisma/client'; + +import { getCategoryLabel } from '~/lib/categories'; +import { + SETTLEMENTS_FILTER, + filterExpenses, + getFilterOptions, + matchesExpenseQuery, +} from '~/utils/expenseSearch'; + +const expense = ( + name: string, + category: string, + amount: number, + splitType: SplitType = SplitType.EQUAL, +) => ({ name, category, amount, splitType }); + +const expenses = [ + expense('Café de Flore', 'diningOut', 4550), + expense('Weekly groceries', 'groceries', 12000), + expense('Taxi to airport', 'taxi', 4500), + expense('Settle up', 'general', 3000, SplitType.SETTLEMENT), + expense('Supermarket', 'groceries', -800), +]; + +const names = (list: { name: string }[]) => list.map((e) => e.name); + +void describe('expense search', () => { + void it('matches the name ignoring case and accents', () => { + assert.ok(matchesExpenseQuery(expenses[0]!, 'cafe')); + assert.ok(matchesExpenseQuery(expenses[0]!, 'FLORE')); + assert.ok(!matchesExpenseQuery(expenses[0]!, 'taxi')); + }); + + void it('needs every word, each found in the name, category or other text', () => { + assert.ok(matchesExpenseQuery(expenses[1]!, 'weekly groceries')); + assert.ok(matchesExpenseQuery(expenses[0]!, 'dining flore')); + assert.ok(matchesExpenseQuery(expenses[2]!, 'alice taxi', ['Alice', 'Trip to Rome'])); + assert.ok(matchesExpenseQuery(expenses[2]!, 'rome', ['Alice', 'Trip to Rome'])); + assert.ok(!matchesExpenseQuery(expenses[2]!, 'bob taxi', ['Alice', null, undefined])); + }); + + void it('matches typed amounts against the whole units and the cents typed', () => { + assert.deepEqual(names(filterExpenses(expenses, { query: '45', category: null })), [ + 'Café de Flore', + 'Taxi to airport', + ]); + assert.deepEqual(names(filterExpenses(expenses, { query: '45.5', category: null })), [ + 'Café de Flore', + ]); + assert.deepEqual(names(filterExpenses(expenses, { query: '45,00', category: null })), [ + 'Taxi to airport', + ]); + assert.deepEqual(names(filterExpenses(expenses, { query: '8', category: null })), [ + 'Supermarket', + ]); + }); + + void it('filters by category, with settlements as their own filter', () => { + assert.deepEqual(names(filterExpenses(expenses, { query: '', category: 'groceries' })), [ + 'Weekly groceries', + 'Supermarket', + ]); + assert.deepEqual(names(filterExpenses(expenses, { query: '', category: SETTLEMENTS_FILTER })), [ + 'Settle up', + ]); + assert.deepEqual(names(filterExpenses(expenses, { query: 'weekly', category: 'groceries' })), [ + 'Weekly groceries', + ]); + assert.equal(filterExpenses(expenses, { query: ' ', category: null }).length, expenses.length); + }); + + void it('lists the categories found, most used first', () => { + assert.deepEqual(getFilterOptions(expenses), [ + { key: 'groceries', label: 'Groceries' }, + { key: 'diningOut', label: 'Dining Out' }, + { key: SETTLEMENTS_FILTER, label: 'Settlements' }, + { key: 'taxi', label: 'Taxi' }, + ]); + }); + + void it('labels group categories and unknown keys', () => { + assert.equal(getCategoryLabel('food'), 'Food & Drinks'); + assert.equal(getCategoryLabel('general'), 'General'); + assert.equal(getCategoryLabel('somethingNew'), 'somethingNew'); + }); +}); diff --git a/src/utils/expenseSearch.ts b/src/utils/expenseSearch.ts new file mode 100644 index 0000000..650e631 --- /dev/null +++ b/src/utils/expenseSearch.ts @@ -0,0 +1,91 @@ +import { SplitType } from '@prisma/client'; +import { getCategoryLabel } from '~/lib/categories'; + +/** The category filter that keeps only settlements. No stored category has this key. */ +export const SETTLEMENTS_FILTER = 'settlements'; + +export type ExpenseFilter = { + query: string; + /** A category key, `SETTLEMENTS_FILTER`, or null for everything. */ + category: string | null; +}; + +export type SearchableExpense = { + name: string; + category: string; + amount: number; + splitType: SplitType; +}; + +/** Lowercase without accents, so "cafe" finds "Café". */ +const normalize = (text: string) => + text + .normalize('NFD') + .replace(/\p{Diacritic}/gu, '') + .toLowerCase(); + +/** The filter key of an expense: its category, or `SETTLEMENTS_FILTER` for a settlement. */ +export const getFilterKey = (expense: SearchableExpense) => + expense.splitType === SplitType.SETTLEMENT ? SETTLEMENTS_FILTER : expense.category; + +export const getFilterLabel = (key: string) => + SETTLEMENTS_FILTER === key ? 'Settlements' : getCategoryLabel(key); + +/** + * Whether a number typed in the search matches an amount in cents. "45" matches 45.00 to 45.99, + * "45.5" matches 45.50 to 45.59 and "45,50" matches 45.50, whatever the sign of the amount. + */ +const matchesAmount = (term: string, amount: number) => { + const match = /^(\d+)(?:[.,](\d{0,2}))?$/.exec(term); + if (!match) { + return false; + } + + const [units, cents] = (Math.abs(amount) / 100).toFixed(2).split('.'); + return units === String(Number(match[1])) && (cents ?? '').startsWith(match[2] ?? ''); +}; + +/** + * Whether every word of the query is found in the expense name, its category, one of `otherText` + * (payer, group…) or its amount. + */ +export const matchesExpenseQuery = ( + expense: SearchableExpense, + query: string, + otherText: (string | null | undefined)[] = [], +) => { + const terms = normalize(query).split(/\s+/).filter(Boolean); + if (0 === terms.length) { + return true; + } + + const text = normalize( + [expense.name, getFilterLabel(getFilterKey(expense)), ...otherText].filter(Boolean).join('\n'), + ); + + return terms.every((term) => text.includes(term) || matchesAmount(term, expense.amount)); +}; + +export const filterExpenses = ( + expenses: T[], + filter: ExpenseFilter, + getOtherText?: (expense: T) => (string | null | undefined)[], +) => + expenses.filter( + (expense) => + (null === filter.category || getFilterKey(expense) === filter.category) && + matchesExpenseQuery(expense, filter.query, getOtherText?.(expense)), + ); + +/** The filter keys found in a list, most used first, for the category chips. */ +export const getFilterOptions = (expenses: SearchableExpense[]) => { + const counts = new Map(); + for (const expense of expenses) { + const key = getFilterKey(expense); + counts.set(key, (counts.get(key) ?? 0) + 1); + } + + return [...counts.entries()] + .sort(([keyA, countA], [keyB, countB]) => countB - countA || keyA.localeCompare(keyB)) + .map(([key]) => ({ key, label: getFilterLabel(key) })); +};