Skip to content

Commit a858170

Browse files
vin-nikrokosik
andauthored
Clarify split equally 321 (#324)
* Add dynamic split description for expense splits Introduces a utility to generate context-aware split descriptions in AddExpense, updating UI to show more informative labels based on split type, payer, and participant count. Localization keys for new descriptions were added to all supported languages. * Update splitDescriptions.ts * We already have the paid and for keys in the common namespace, please do not duplicate them. * Please move this function to string utils * Include this function in the useTranslationWithUtils hook, so that passing t is not required. * fixed sizing because it was cutting off the number on tablet sizes * Simplify useTranslationWithUtils hook using useMemo * Simplify useTranslationWithUtils properly * Fix usages of toUIDate * Remove redundant split_equally_count key --------- Co-authored-by: Wiktor Krokosz <38408316+krokosik@users.noreply.github.com> Co-authored-by: krokosik <krokosik@pm.me>
1 parent a0bb7e4 commit a858170

File tree

8 files changed

+97
-31
lines changed

8 files changed

+97
-31
lines changed

src/components/AddExpense/AddExpensePage.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@ import { useAddExpenseStore } from '~/store/addStore';
1010
import { api } from '~/utils/api';
1111
import { toSafeBigInt } from '~/utils/numbers';
1212

13-
import { toUIDate } from '~/utils/strings';
1413
import { Button } from '../ui/button';
1514
import { Calendar } from '../ui/calendar';
1615
import { Input } from '../ui/input';
@@ -22,13 +21,14 @@ import { SplitTypeSection } from './SplitTypeSection';
2221
import { UploadFile } from './UploadFile';
2322
import { UserInput } from './UserInput';
2423
import { toast } from 'sonner';
24+
import { useTranslationWithUtils } from '~/hooks/useTranslationWithUtils';
2525

2626
export const AddOrEditExpensePage: React.FC<{
2727
isStorageConfigured: boolean;
2828
enableSendingInvites: boolean;
2929
expenseId?: string;
3030
}> = ({ isStorageConfigured, enableSendingInvites, expenseId }) => {
31-
const { t } = useTranslation('expense_details');
31+
const { t, toUIDate } = useTranslationWithUtils(['expense_details']);
3232
const showFriends = useAddExpenseStore((s) => s.showFriends);
3333
const amount = useAddExpenseStore((s) => s.amount);
3434
const isNegative = useAddExpenseStore((s) => s.isNegative);

src/components/AddExpense/SplitTypeSection.tsx

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,27 +10,28 @@ import {
1010
Plus,
1111
X,
1212
} from 'lucide-react';
13-
import { type TFunction, useTranslation } from 'next-i18next';
1413
import { type ChangeEvent, useCallback, useMemo } from 'react';
14+
import { useTranslationWithUtils } from '~/hooks/useTranslationWithUtils';
1515

1616
import { type AddExpenseState, type Participant, useAddExpenseStore } from '~/store/addStore';
1717
import { removeTrailingZeros, toSafeBigInt, toUIString } from '~/utils/numbers';
1818

19+
import { type TFunction, useTranslation } from 'next-i18next';
1920
import { EntityAvatar } from '../ui/avatar';
2021
import { AppDrawer, DrawerClose } from '../ui/drawer';
2122
import { Input } from '../ui/input';
2223
import { Tabs, TabsContent, TabsList, TabsTrigger } from '../ui/tabs';
23-
import { useTranslationWithUtils } from '~/hooks/useTranslationWithUtils';
2424

2525
export const SplitTypeSection: React.FC = () => {
26-
const { t, displayName } = useTranslationWithUtils(['expense_details']);
26+
const { t, displayName, generateSplitDescription } = useTranslationWithUtils(['expense_details']);
2727
const isNegative = useAddExpenseStore((s) => s.isNegative);
2828
const paidBy = useAddExpenseStore((s) => s.paidBy);
2929
const participants = useAddExpenseStore((s) => s.participants);
3030
const currentUser = useAddExpenseStore((s) => s.currentUser);
3131
const canSplitScreenClosed = useAddExpenseStore((s) => s.canSplitScreenClosed);
3232
const splitType = useAddExpenseStore((s) => s.splitType);
3333
const splitScreenOpen = useAddExpenseStore((s) => s.splitScreenOpen);
34+
const splitShares = useAddExpenseStore((s) => s.splitShares);
3435

3536
const { setPaidBy, setSplitScreenOpen } = useAddExpenseStore((s) => s.actions);
3637

@@ -68,10 +69,8 @@ export const SplitTypeSection: React.FC = () => {
6869
<p>{t('ui.and', { ns: 'common' })} </p>
6970
<AppDrawer
7071
trigger={
71-
<div className="max-w-32 overflow-hidden px-1.5 text-[16.5px] text-nowrap text-ellipsis text-cyan-500 lg:max-w-48">
72-
{splitType === SplitType.EQUAL
73-
? t('ui.add_expense_details.split_type_section.split_equally')
74-
: t('ui.add_expense_details.split_type_section.split_unequally')}
72+
<div className="max-w-40 overflow-hidden px-1.5 text-[16.5px] text-nowrap text-ellipsis text-cyan-500 md:max-w-48 lg:max-w-56">
73+
{generateSplitDescription(splitType, participants, splitShares, paidBy, currentUser)}
7574
</div>
7675
}
7776
title={t(

src/components/Expense/ExpenseDetails.tsx

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ import { type User as NextUser } from 'next-auth';
44

55
import { toUIString } from '~/utils/numbers';
66

7-
import { toUIDate } from '~/utils/strings';
87
import { EntityAvatar } from '../ui/avatar';
98
import { Separator } from '../ui/separator';
109
import { Receipt } from './Receipt';
@@ -25,7 +24,7 @@ interface ExpenseDetailsProps {
2524
}
2625

2726
const ExpenseDetails: FC<ExpenseDetailsProps> = ({ user, expense, storagePublicUrl }) => {
28-
const { displayName, t } = useTranslationWithUtils(['expense_details']);
27+
const { displayName, toUIDate, t } = useTranslationWithUtils(['expense_details']);
2928
return (
3029
<>
3130
<div className="mb-4 flex items-start justify-between gap-2">

src/components/Expense/ExpenseList.tsx

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ import React from 'react';
66
import { CategoryIcon } from '~/components/ui/categoryIcons';
77
import type { ExpenseRouter } from '~/server/api/routers/expense';
88
import { toUIString } from '~/utils/numbers';
9-
import { toUIDate } from '~/utils/strings';
109
import { useTranslationWithUtils } from '~/hooks/useTranslationWithUtils';
1110

1211
export const ExpenseList: React.FC<{
@@ -18,7 +17,7 @@ export const ExpenseList: React.FC<{
1817
isGroup?: boolean;
1918
isLoading?: boolean;
2019
}> = ({ userId, isGroup = false, expenses = [], contactId, isLoading }) => {
21-
const { displayName, t } = useTranslationWithUtils(['expense_details']);
20+
const { displayName, toUIDate, t } = useTranslationWithUtils(['expense_details']);
2221

2322
return (
2423
<>
Lines changed: 27 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,19 @@
1-
import { type User } from '@prisma/client';
21
import { useTranslation } from 'next-i18next';
3-
import { useCallback } from 'react';
4-
import { displayName as dn } from '~/utils/strings';
2+
import { useCallback, useMemo } from 'react';
3+
import {
4+
type ParametersExceptTranslation,
5+
displayName as dn,
6+
generateSplitDescription as gsd,
7+
toUIDate as tUD,
8+
} from '~/utils/strings';
59

610
export const useTranslationWithUtils = (
711
namespaces?: string[],
8-
): ReturnType<typeof useTranslation> & { displayName: typeof displayName } => {
12+
): ReturnType<typeof useTranslation> & {
13+
displayName: typeof displayName;
14+
generateSplitDescription: typeof generateSplitDescription;
15+
toUIDate: typeof toUIDate;
16+
} => {
917
if (!namespaces || namespaces.length === 0) {
1018
namespaces = ['common'];
1119
} else if (!namespaces.includes('common')) {
@@ -14,14 +22,23 @@ export const useTranslationWithUtils = (
1422
const translation = useTranslation(namespaces);
1523

1624
const displayName = useCallback(
17-
(user?: Pick<User, 'name' | 'email' | 'id'> | null, currentUserId?: number): string =>
18-
dn(user, currentUserId, translation.t),
25+
(...args: ParametersExceptTranslation<typeof dn>): string => dn(translation.t, ...args),
26+
[translation.t],
27+
);
28+
29+
const generateSplitDescription = useCallback(
30+
(...args: ParametersExceptTranslation<typeof gsd>): string => gsd(translation.t, ...args),
31+
[translation.t],
32+
);
33+
34+
const toUIDate = useCallback(
35+
(...args: ParametersExceptTranslation<typeof tUD>): string => tUD(translation.t, ...args),
1936
[translation.t],
2037
);
2138

2239
// @ts-ignore
23-
return {
24-
...translation,
25-
displayName,
26-
};
40+
return useMemo(
41+
() => ({ ...translation, displayName, generateSplitDescription, toUIDate }),
42+
[translation, displayName, generateSplitDescription, toUIDate],
43+
);
2744
};

src/pages/activity.tsx

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ import { EntityAvatar } from '~/components/ui/avatar';
77
import { type NextPageWithUser } from '~/types';
88
import { api } from '~/utils/api';
99
import { BigMath, toUIString } from '~/utils/numbers';
10-
import { toUIDate } from '~/utils/strings';
1110
import { type TFunction } from 'next-i18next';
1211
import { useTranslationWithUtils } from '~/hooks/useTranslationWithUtils';
1312
import { withI18nStaticProps } from '~/utils/i18n/server';
@@ -46,7 +45,7 @@ function getPaymentString(
4645
}
4746

4847
const ActivityPage: NextPageWithUser = ({ user }) => {
49-
const { displayName, t } = useTranslationWithUtils();
48+
const { displayName, t, toUIDate } = useTranslationWithUtils();
5049
const expensesQuery = api.expense.getAllExpenses.useQuery();
5150

5251
return (

src/pages/groups/[groupId].tsx

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,12 +39,11 @@ import { type NextPageWithUser } from '~/types';
3939
import { api } from '~/utils/api';
4040
import { customServerSideTranslations } from '~/utils/i18n/server';
4141
import { toUIString } from '~/utils/numbers';
42-
import { toUIDate } from '~/utils/strings';
4342

4443
const BalancePage: NextPageWithUser<{
4544
enableSendingInvites: boolean;
4645
}> = ({ user, enableSendingInvites }) => {
47-
const { displayName, t } = useTranslationWithUtils(['groups_details']);
46+
const { displayName, toUIDate, t } = useTranslationWithUtils(['groups_details']);
4847
const router = useRouter();
4948
const groupId = parseInt(router.query.groupId as string);
5049

src/utils/strings.ts

Lines changed: 58 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,71 @@
1-
import { type User } from '@prisma/client';
1+
import { SplitType, type User } from '@prisma/client';
22
import { format, isToday } from 'date-fns';
33
import { type TFunction } from 'next-i18next';
4+
import { type AddExpenseState, type Participant } from '~/store/addStore';
5+
6+
export type ParametersExceptTranslation<F> = F extends (t: TFunction, ...rest: infer R) => any
7+
? R
8+
: never;
49

510
export const displayName = (
11+
t?: TFunction,
612
user?: Pick<User, 'name' | 'email' | 'id'> | null,
713
currentUserId?: number,
8-
t?: TFunction,
914
): string => {
1015
if (currentUserId === user?.id) {
1116
return t ? t('ui.actors.you', { ns: 'common' }) : 'You';
1217
}
1318
return user?.name ?? user?.email ?? '';
1419
};
1520

16-
export const toUIDate = (date: Date, { useToday = false, year = false } = {}): string =>
17-
useToday && isToday(date) ? 'Today' : format(date, year ? 'dd MMM yyyy' : 'MMM dd');
21+
export const toUIDate = (
22+
t: TFunction,
23+
date: Date,
24+
{ useToday = false, year = false } = {},
25+
): string =>
26+
useToday && isToday(date) ? t('ui.today') : format(date, year ? 'dd MMM yyyy' : 'MMM dd');
27+
28+
export function generateSplitDescription(
29+
t: TFunction,
30+
splitType: SplitType,
31+
participants: Participant[],
32+
splitShares: AddExpenseState['splitShares'],
33+
paidBy: Participant | undefined,
34+
currentUser: Participant | undefined,
35+
): string {
36+
// Only enhance the description for EQUAL split type
37+
if (splitType !== SplitType.EQUAL) {
38+
return t('ui.add_expense_details.split_type_section.split_unequally');
39+
}
40+
41+
if (!paidBy || !currentUser) {
42+
return t('ui.add_expense_details.split_type_section.split_equally');
43+
}
44+
45+
// Get participants who are actually selected for the split (have non-zero shares)
46+
// If split shares are not initialized yet (undefined), include all participants
47+
const selectedParticipants = participants.filter((p) => {
48+
const share = splitShares[p.id]?.[SplitType.EQUAL];
49+
return share === undefined || share !== 0n;
50+
});
51+
52+
// If no one is selected, fall back to default
53+
if (selectedParticipants.length === 0) {
54+
return t('ui.add_expense_details.split_type_section.split_equally');
55+
}
56+
57+
// Case 1: Paying for exactly one person
58+
if (selectedParticipants.length === 1) {
59+
const beneficiary = selectedParticipants[0];
60+
const beneficiaryName = beneficiary?.name?.split(' ')[0] || beneficiary?.email || 'someone';
61+
return `${t('common:ui.expense.user.paid')} ${t('common:ui.expense.for')} ${beneficiaryName}`;
62+
}
63+
64+
// Case 2: Splitting with multiple people
65+
if (selectedParticipants.length > 1) {
66+
return `t('ui.add_expense_details.split_type_section.split_equally') (${selectedParticipants.length})`;
67+
}
68+
69+
// Fallback to default for all other cases
70+
return t('ui.add_expense_details.split_type_section.split_equally');
71+
}

0 commit comments

Comments
 (0)