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) }));
+};