Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,8 @@ pnpm dev:mobile
### Web
- Next.js dashboard and marketing pages
- Theme is controlled via `data-theme` and local storage
- Auth tokens for the web app are stored only in httpOnly cookies managed by `apps/web/app/api/auth/*`, `apps/web/src/lib/backend.ts`, and `apps/web/middleware.ts`
- `localStorage` is not part of the web auth flow
- Build: `pnpm --filter web build`

### Mobile
Expand Down
83 changes: 81 additions & 2 deletions apps/backend/src/activity/activity.service.spec.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,86 @@
import { ActivityService } from './activity.service';

describe('ActivityService', () => {
it('should be defined', () => {
expect(ActivityService).toBeDefined();
it('maps actor and group names for group activity', async () => {
const prisma = {
activity: {
findMany: jest.fn().mockResolvedValue([
{
id: 'activity-1',
groupId: 'group-1',
actorUserId: 'user-1',
type: 'expense_created',
entityId: 'expense-1',
metadata: { totalAmountCents: '1234', currency: 'USD' },
createdAt: new Date('2026-04-07T00:00:00.000Z'),
actor: { name: 'Ava' },
group: { name: 'Trip Fund' },
},
]),
},
user: {
findMany: jest.fn().mockResolvedValue([
{ id: 'user-1', name: 'Ava' },
]),
},
};

const service = new ActivityService(prisma as any);
const result = await service.getGroupActivity('group-1');

expect(prisma.activity.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { groupId: 'group-1' },
include: {
actor: { select: { name: true } },
group: { select: { name: true } },
},
}),
);
expect(prisma.user.findMany).toHaveBeenCalledWith({
where: { id: { in: ['user-1'] } },
select: { id: true, name: true },
});
expect(result.items).toEqual([
expect.objectContaining({
actorUserId: 'user-1',
actorName: 'Ava',
groupName: 'Trip Fund',
}),
]);
});

it('keeps responses backward compatible when names are missing', async () => {
const prisma = {
activity: {
findMany: jest.fn().mockResolvedValue([
{
id: 'activity-2',
groupId: 'group-2',
actorUserId: 'user-2',
type: 'member_joined',
entityId: 'member-1',
metadata: {},
createdAt: new Date('2026-04-07T00:00:00.000Z'),
actor: null,
group: null,
},
]),
},
user: {
findMany: jest.fn().mockResolvedValue([]),
},
};

const service = new ActivityService(prisma as any);
const result = await service.getUserActivity('user-2');

expect(result.items[0]).toEqual(
expect.objectContaining({
actorUserId: 'user-2',
actorName: undefined,
groupName: undefined,
}),
);
});
});
92 changes: 72 additions & 20 deletions apps/backend/src/activity/activity.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,66 @@ import { PrismaService } from '../common/prisma.service';
export class ActivityService {
constructor(private readonly prisma: PrismaService) {}

private enrichMetadata(metadata: Record<string, unknown>, userNamesById: Record<string, string>): Record<string, unknown> {
const payerId = typeof metadata.payerId === 'string' ? metadata.payerId : null;
const receiverId = typeof metadata.receiverId === 'string' ? metadata.receiverId : null;

return {
...metadata,
...(payerId && userNamesById[payerId] ? { payerName: userNamesById[payerId] } : {}),
...(receiverId && userNamesById[receiverId] ? { receiverName: userNamesById[receiverId] } : {}),
};
}

private async mapActivities(events: Array<{
id: string;
groupId: string;
actorUserId: string;
type: ActivityType;
entityId: string;
metadata: Prisma.JsonValue;
createdAt: Date;
actor?: { name: string | null } | null;
group?: { name: string | null } | null;
}>): Promise<ActivityDto[]> {
const userIds = new Set<string>();

events.forEach((event) => {
userIds.add(event.actorUserId);
const metadata = event.metadata as Record<string, unknown>;
if (typeof metadata.payerId === 'string') {
userIds.add(metadata.payerId);
}
if (typeof metadata.receiverId === 'string') {
userIds.add(metadata.receiverId);
}
});

const users = userIds.size
? await this.prisma.user.findMany({
where: { id: { in: [...userIds] } },
select: { id: true, name: true },
})
: [];
const userNamesById = Object.fromEntries(users.map((user) => [user.id, user.name]));

return events.map((event) => {
const metadata = event.metadata as Record<string, unknown>;

return {
id: event.id,
groupId: event.groupId,
actorUserId: event.actorUserId,
actorName: userNamesById[event.actorUserId] ?? event.actor?.name ?? undefined,
groupName: event.group?.name ?? undefined,
type: event.type,
entityId: event.entityId,
metadata: this.enrichMetadata(metadata, userNamesById),
createdAt: event.createdAt.toISOString(),
};
});
}

async log(params: {
groupId: string;
actorUserId: string;
Expand All @@ -18,7 +78,7 @@ export class ActivityService {
data: {
groupId: params.groupId,
actorUserId: params.actorUserId,
type: params.type,
type: params.type as Prisma.ActivityType,
entityId: params.entityId,
metadata: (params.metadata ?? {}) as Prisma.InputJsonValue,
},
Expand All @@ -38,17 +98,13 @@ export class ActivityService {
orderBy: { createdAt: 'desc' },
skip: safeCursor,
take: safeLimit,
include: {
actor: { select: { name: true } },
group: { select: { name: true } },
},
});

const items = events.map((event) => ({
id: event.id,
groupId: event.groupId,
actorUserId: event.actorUserId,
type: event.type,
entityId: event.entityId,
metadata: event.metadata as Record<string, unknown>,
createdAt: event.createdAt.toISOString(),
}));
const items = await this.mapActivities(events);

return {
items,
Expand All @@ -75,21 +131,17 @@ export class ActivityService {
orderBy: { createdAt: 'desc' },
skip: safeCursor,
take: safeLimit,
include: {
actor: { select: { name: true } },
group: { select: { name: true } },
},
});

const items = events.map((event) => ({
id: event.id,
groupId: event.groupId,
actorUserId: event.actorUserId,
type: event.type,
entityId: event.entityId,
metadata: event.metadata as Record<string, unknown>,
createdAt: event.createdAt.toISOString(),
}));
const items = await this.mapActivities(events);

return {
items,
nextCursor: events.length === safeLimit ? safeCursor + safeLimit : null,
};
}
}
}
12 changes: 4 additions & 8 deletions apps/backend/src/common/utils/money.util.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { CurrencyCode, formatCurrencyFromCents } from '@fairshare/shared-types';

export const sumMoney = (values: Array<bigint | number | string>): bigint =>
values.reduce<bigint>((acc, value) => acc + BigInt(value), 0n);

Expand All @@ -7,11 +9,5 @@ export const assertMoneyEquality = (left: bigint, right: bigint, message = 'Mone
}
};

export const formatMoney = (amountCents: bigint | number | string, currency = 'USD'): string => {
const value = Number(BigInt(amountCents)) / 100;
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency,
maximumFractionDigits: 2,
}).format(value);
};
export const formatMoney = (amountCents: bigint | number | string, currency: CurrencyCode = 'USD'): string =>
formatCurrencyFromCents(amountCents, currency);
3 changes: 1 addition & 2 deletions apps/mobile/.turbo/turbo-build.log
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@

> mobile@1.0.0 build D:\ak\project\FairShare\apps\mobile
> mobile@1.0.0 build /home/jailuser/git/apps/mobile
> tsc --noEmit

17 changes: 12 additions & 5 deletions apps/mobile/app/components/ui/MoneyText.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,25 @@
import React from 'react';
import { StyleSheet, Text as RNText } from 'react-native';
import { formatCurrencyFromCents, type CurrencyCode } from '@fairshare/shared-types';
import { useAppTheme } from '../../theme/useAppTheme';

interface MoneyTextProps {
cents: string;
size?: 'sm' | 'md' | 'lg';
variant?: 'default' | 'success' | 'danger';
currency?: string;
currency?: CurrencyCode;
}

/**
* Renders a Text element that displays a currency amount formatted from a cents string and sets the accessibility label to the same formatted string.
*
* @param cents - Amount in cents as a string (for example, `"150"` represents $1.50)
* @param currency - ISO currency code used for formatting (defaults to `'USD'`)
* @returns A React element that renders the formatted currency string
*/
export function MoneyText({ cents, size = 'md', variant = 'default', currency = 'USD' }: MoneyTextProps) {
const { colors } = useAppTheme();
const amount = Number(cents) / 100;
const symbol = currency === 'INR' ? '₹' : '$';
const amount = formatCurrencyFromCents(cents, currency);

const colorMap = {
default: colors.text_primary,
Expand All @@ -35,9 +42,9 @@ export function MoneyText({ cents, size = 'md', variant = 'default', currency =
fontSize: sizeMap[size],
},
]}
accessibilityLabel={`${symbol}${amount.toFixed(2)}`}
accessibilityLabel={amount}
>
{symbol}{amount.toFixed(2)}
{amount}
</RNText>
);
}
Expand Down
28 changes: 21 additions & 7 deletions apps/mobile/app/screens/ActivityScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { FlatList, RefreshControl, StyleSheet, View } from 'react-native';
import { Text } from 'react-native-paper';
import { MaterialCommunityIcons } from '@expo/vector-icons';
import Animated, { FadeInDown } from 'react-native-reanimated';
import type { ActivityDto, ActivityType } from '@fairshare/shared-types';
import { formatCurrencyFromCents, type ActivityDto, type ActivityType } from '@fairshare/shared-types';
import { groupService } from '../services/group.service';
import { useToastStore } from '../store/toastStore';
import { useAppTheme } from '../theme/useAppTheme';
Expand Down Expand Up @@ -34,7 +34,7 @@ const colorByType: Record<ActivityType, string> = {
};

const actionText = (activity: ActivityDto): string => {
const actor = activity.actorUserId;
const actor = activity.actorName ?? activity.actorUserId;
switch (activity.type) {
case 'expense_created':
return `${actor} created an expense`;
Expand All @@ -45,9 +45,9 @@ const actionText = (activity: ActivityDto): string => {
case 'settlement_created':
return `${actor} recorded a settlement`;
case 'settlement_reminder': {
const payerId = typeof activity.metadata?.payerId === 'string' ? activity.metadata.payerId : 'a member';
const receiverId = typeof activity.metadata?.receiverId === 'string' ? activity.metadata.receiverId : 'another member';
return `${actor} reminded ${payerId} to pay ${receiverId}`;
const payerName = typeof activity.metadata?.payerName === 'string' ? activity.metadata.payerName : 'a member';
const receiverName = typeof activity.metadata?.receiverName === 'string' ? activity.metadata.receiverName : 'another member';
return `${actor} reminded ${payerName} to pay ${receiverName}`;
}
case 'member_joined':
return `${actor} joined the group`;
Expand All @@ -63,6 +63,11 @@ const extractAmountCents = (activity: ActivityDto): string | null => {
return typeof raw === 'string' ? raw : null;
};

const resolveActivityCurrency = (activity: ActivityDto) => {
const currency = activity.metadata?.currency;
return currency === 'USD' || currency === 'EUR' || currency === 'INR' ? currency : 'USD';
};

const relativeTime = (iso: string): string => {
const now = Date.now();
const timestamp = new Date(iso).getTime();
Expand All @@ -77,6 +82,15 @@ const relativeTime = (iso: string): string => {
return `${diffDay}d ago`;
};

/**
* Renders the Activity screen with a paginated, refreshable list of activity events for a group or the current user.
*
* The screen supports pull-to-refresh, infinite scroll pagination, animated item entry, and displays loading or empty states.
* Amounts and currencies are derived from activity metadata when available.
*
* @param route - Optional navigation route. If `route.params.groupId` is provided the screen shows that group's activity; otherwise it shows the current user's activity.
* @returns The rendered Activity screen React element.
*/
export function ActivityScreen({ route }: { route?: { params?: { groupId?: string } } }) {
const groupId = route?.params?.groupId;
const toast = useToastStore((state) => state.show);
Expand Down Expand Up @@ -125,7 +139,7 @@ export function ActivityScreen({ route }: { route?: { params?: { groupId?: strin

const renderItem = ({ item, index }: { item: ActivityDto; index: number }) => {
const amountCents = extractAmountCents(item);
const amountText = amountCents ? `$${(Number(amountCents) / 100).toFixed(2)}` : null;
const amountText = amountCents ? formatCurrencyFromCents(amountCents, resolveActivityCurrency(item)) : null;
const iconColor = colorByType[item.type] ?? colors.primary;

return (
Expand All @@ -148,7 +162,7 @@ export function ActivityScreen({ route }: { route?: { params?: { groupId?: strin
{actionText(item)}
</Text>
<Text style={[styles.time, { color: colors.text_secondary }]}>
{relativeTime(item.createdAt)}
{item.groupName ? `${item.groupName} • ${relativeTime(item.createdAt)}` : relativeTime(item.createdAt)}
</Text>
</View>
{amountText && (
Expand Down
Loading
Loading