Skip to content

Commit a9c9168

Browse files
feat: Implement core group management and activity feed backend, and foundational mobile screens for authentication, groups, expenses, and activity.
1 parent 1a83ca7 commit a9c9168

11 files changed

Lines changed: 168 additions & 61 deletions

File tree

apps/backend/src/activity/activity.controller.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,18 @@
1-
import { Controller, Get, Param, Query, UseGuards } from '@nestjs/common';
1+
import { Controller, Get, Param, Query, UseGuards, Request } from '@nestjs/common';
22
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
33
import { ActivityService } from './activity.service';
44

5-
@Controller('groups/:id/activity')
5+
@Controller('activity')
66
@UseGuards(JwtAuthGuard)
77
export class ActivityController {
88
constructor(private readonly activityService: ActivityService) {}
99

1010
@Get()
11+
getUserActivity(@Request() req: any, @Query('cursor') cursor = '0', @Query('limit') limit = '20') {
12+
return this.activityService.getUserActivity(req.user.id, Number(cursor), Number(limit));
13+
}
14+
15+
@Get('group/:id')
1116
getGroupActivity(@Param('id') groupId: string, @Query('cursor') cursor = '0', @Query('limit') limit = '20') {
1217
return this.activityService.getGroupActivity(groupId, Number(cursor), Number(limit));
1318
}

apps/backend/src/activity/activity.service.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,4 +55,41 @@ export class ActivityService {
5555
nextCursor: events.length === safeLimit ? safeCursor + safeLimit : null,
5656
};
5757
}
58+
59+
async getUserActivity(
60+
userId: string,
61+
cursor = 0,
62+
limit = 20,
63+
): Promise<{ items: ActivityDto[]; nextCursor: number | null }> {
64+
const safeCursor = Number.isFinite(cursor) && cursor >= 0 ? cursor : 0;
65+
const safeLimit = Number.isFinite(limit) && limit > 0 ? Math.min(limit, 100) : 20;
66+
67+
const events = await this.prisma.activity.findMany({
68+
where: {
69+
group: {
70+
members: {
71+
some: { userId },
72+
},
73+
},
74+
},
75+
orderBy: { createdAt: 'desc' },
76+
skip: safeCursor,
77+
take: safeLimit,
78+
});
79+
80+
const items = events.map((event) => ({
81+
id: event.id,
82+
groupId: event.groupId,
83+
actorUserId: event.actorUserId,
84+
type: event.type,
85+
entityId: event.entityId,
86+
metadata: event.metadata as Record<string, unknown>,
87+
createdAt: event.createdAt.toISOString(),
88+
}));
89+
90+
return {
91+
items,
92+
nextCursor: events.length === safeLimit ? safeCursor + safeLimit : null,
93+
};
94+
}
5895
}

apps/backend/src/groups/groups.controller.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,11 @@ export class GroupsController {
2222
return this.groupsService.list(user.sub);
2323
}
2424

25+
@Get('summary')
26+
getUserSummary(@CurrentUser() user: JwtPayload) {
27+
return this.groupsService.getUserSummary(user.sub);
28+
}
29+
2530
@Get(':id')
2631
getById(@Param('id') id: string, @CurrentUser() user: JwtPayload) {
2732
return this.groupsService.getById(id, user.sub);

apps/backend/src/groups/groups.service.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,19 @@ export class GroupsService {
196196
return response;
197197
}
198198

199+
async getUserSummary(userId: string): Promise<{ totalBalanceCents: string }> {
200+
const balances = await this.prisma.balance.findMany({
201+
where: { userId },
202+
select: { amountCents: true },
203+
});
204+
205+
const totalBalance = balances.reduce((acc, curr) => acc + curr.amountCents, 0n);
206+
207+
return {
208+
totalBalanceCents: totalBalance.toString(),
209+
};
210+
}
211+
199212
async invite(groupId: string, actorUserId: string, dto: InviteMemberDto): Promise<{ success: true }> {
200213
await this.assertMembership(groupId, actorUserId);
201214
const email = dto.email.toLowerCase();

apps/mobile/app/screens/ActivityScreen.tsx

Lines changed: 7 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -80,16 +80,10 @@ export function ActivityScreen({ route }: { route?: { params?: { groupId?: strin
8080
const [nextCursor, setNextCursor] = React.useState<number | null>(0);
8181

8282
const loadFirstPage = React.useCallback(async () => {
83-
if (!groupId) {
84-
setEvents([]);
85-
setNextCursor(null);
86-
setLoading(false);
87-
setRefreshing(false);
88-
return;
89-
}
90-
9183
try {
92-
const data = await groupService.activity(groupId, 0, PAGE_SIZE);
84+
const data = groupId
85+
? await groupService.activity(groupId, 0, PAGE_SIZE)
86+
: await groupService.userActivity(0, PAGE_SIZE);
9387
setEvents(data.items);
9488
setNextCursor(data.nextCursor);
9589
} catch {
@@ -105,12 +99,12 @@ export function ActivityScreen({ route }: { route?: { params?: { groupId?: strin
10599
}, [loadFirstPage]);
106100

107101
const loadMore = async () => {
108-
if (!groupId || nextCursor === null) {
109-
return;
110-
}
102+
if (nextCursor === null) return;
111103

112104
try {
113-
const data = await groupService.activity(groupId, nextCursor, PAGE_SIZE);
105+
const data = groupId
106+
? await groupService.activity(groupId, nextCursor, PAGE_SIZE)
107+
: await groupService.userActivity(nextCursor, PAGE_SIZE);
114108
setEvents((prev) => [...prev, ...data.items]);
115109
setNextCursor(data.nextCursor);
116110
} catch {

apps/mobile/app/screens/AddExpenseScreen.tsx

Lines changed: 22 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import Animated, { FadeInRight, FadeOutLeft, FadeInLeft, FadeOutRight, FadeInDow
99
import type { GroupMemberSummaryDto } from '@fairshare/shared-types';
1010
import { expenseService } from '../services/expense.service';
1111
import { groupService } from '../services/group.service';
12+
import type { GroupDto } from '@fairshare/shared-types';
1213
import { useToastStore } from '../store/toastStore';
1314
import { useAppTheme } from '../theme/useAppTheme';
1415
import { spacing } from '../theme/spacing';
@@ -28,7 +29,7 @@ const STEPS = [
2829
{ title: 'Payer', subtitle: 'Who paid the bill?' },
2930
{ title: 'Participants', subtitle: 'Who is splitting this?' },
3031
{ title: 'Split', subtitle: 'How should we divide it?' },
31-
{ title: 'Review', subtitle: 'Does everything look royal?' },
32+
{ title: 'Review', subtitle: 'Vibe check before we post?' },
3233
];
3334

3435
export function AddExpenseScreen({
@@ -42,6 +43,7 @@ export function AddExpenseScreen({
4243
defaultValues: { description: '', amountCents: '0' },
4344
});
4445
const [members, setMembers] = React.useState<GroupMemberSummaryDto[]>([]);
46+
const [group, setGroup] = React.useState<GroupDto | null>(null);
4547
const [payerId, setPayerId] = React.useState<string>('');
4648
const [splitType, setSplitType] = React.useState<SplitType>('equal');
4749
const [selectedParticipantIds, setSelectedParticipantIds] = React.useState<string[]>([]);
@@ -60,11 +62,15 @@ export function AddExpenseScreen({
6062
React.useEffect(() => {
6163
const loadMembers = async () => {
6264
try {
63-
const data = await groupService.members(route.params.groupId);
64-
setMembers(data);
65-
if (data.length > 0) {
66-
setPayerId(data[0].userId);
67-
setSelectedParticipantIds(data.map((member) => member.userId));
65+
const [memberData, groupData] = await Promise.all([
66+
groupService.members(route.params.groupId),
67+
groupService.get(route.params.groupId),
68+
]);
69+
setMembers(memberData);
70+
setGroup(groupData);
71+
if (memberData.length > 0) {
72+
setPayerId(memberData[0].userId);
73+
setSelectedParticipantIds(memberData.map((member) => member.userId));
6874
}
6975
} catch {
7076
toast('Failed to load members');
@@ -131,7 +137,7 @@ export function AddExpenseScreen({
131137
payerId,
132138
description: values.description,
133139
totalAmountCents: String(totalAmount),
134-
currency: 'USD',
140+
currency: group?.currency ?? 'USD',
135141
splits: selectedParticipantIds.map((userId) => ({
136142
userId,
137143
owedAmountCents: String(shares[userId] ?? 0),
@@ -142,7 +148,7 @@ export function AddExpenseScreen({
142148
setSuccessOpen(true);
143149
setTimeout(() => {
144150
setSuccessOpen(false);
145-
toast('Expense created');
151+
toast('Bet! Split recorded 🤝');
146152
navigation.goBack();
147153
}, 700);
148154
} catch {
@@ -186,7 +192,7 @@ export function AddExpenseScreen({
186192
mode="outlined"
187193
outlineStyle={{ borderRadius: 16 }}
188194
style={styles.input}
189-
left={<TextInput.Affix text="$" />}
195+
left={<TextInput.Affix text={group?.currency === 'INR' ? '₹' : '$'} />}
190196
/>
191197
)}
192198
/>
@@ -301,7 +307,9 @@ export function AddExpenseScreen({
301307
</View>
302308
<View style={styles.reviewItem}>
303309
<Text style={[typography.caption, { color: colors.muted }]}>TOTAL AMOUNT</Text>
304-
<Text style={[typography.h1, { color: colors.primary }]}>${(totalAmount / 100).toFixed(2)}</Text>
310+
<Text style={[typography.h1, { color: colors.primary }]}>
311+
{group?.currency === 'INR' ? '₹' : '$'}{(totalAmount / 100).toFixed(2)}
312+
</Text>
305313
</View>
306314
<View style={styles.reviewItem}>
307315
<Text style={[typography.caption, { color: colors.muted }]}>PAID BY</Text>
@@ -325,7 +333,9 @@ export function AddExpenseScreen({
325333
<Avatar name={member?.name ?? 'U'} size={20} />
326334
<Text style={[typography.bodyMedium, { color: colors.text_primary, fontWeight: '600' }]}>{member?.name ?? 'Unknown'}</Text>
327335
</View>
328-
<Text style={[typography.bodyMedium, { color: colors.text_primary, fontWeight: '800' }]}>${(shareAmount / 100).toFixed(2)}</Text>
336+
<Text style={[typography.bodyMedium, { color: colors.text_primary, fontWeight: '800' }]}>
337+
{group?.currency === 'INR' ? '₹' : '$'}{(shareAmount / 100).toFixed(2)}
338+
</Text>
329339
</View>
330340
);
331341
})}
@@ -398,7 +408,7 @@ export function AddExpenseScreen({
398408
loop={false}
399409
style={{ width: 140, height: 140 }}
400410
/>
401-
<Text style={[typography.h2, { color: colors.text_primary }]}>Royal Success!</Text>
411+
<Text style={[typography.h2, { color: colors.text_primary }]}>No cap, success! ✨</Text>
402412
<Text style={[typography.bodyMedium, { color: colors.text_secondary, textAlign: 'center' }]}>
403413
The expense has been successfully split.
404414
</Text>

apps/mobile/app/screens/HomeScreen.tsx

Lines changed: 65 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -9,21 +9,51 @@ import { useAppTheme } from '../theme/useAppTheme';
99
import { spacing } from '../theme/spacing';
1010
import { BalanceCard } from '../components/BalanceCard';
1111
import { SectionHeader } from '../components/SectionHeader';
12-
import { ActivityItem } from '../components/ActivityItem';
13-
import { Button } from '../components/ui/Button';
1412
import { Avatar } from '../components/ui/Avatar';
13+
import { groupService } from '../services/group.service';
14+
import type { ActivityDto } from '@fairshare/shared-types';
15+
import { useToastStore } from '../store/toastStore';
1516

1617
export function HomeScreen({ navigation }: { navigation: any }) {
1718
const user = useAuthStore((state) => state.user);
1819
const groups = useGroupStore((state) => state.groups);
1920
const { colors, typography } = useAppTheme();
21+
const [summary, setSummary] = React.useState<{ totalBalanceCents: string } | null>(null);
22+
const [activities, setActivities] = React.useState<ActivityDto[]>([]);
23+
const [loading, setLoading] = React.useState(true);
24+
const toast = useToastStore((state) => state.show);
25+
26+
const loadData = React.useCallback(async () => {
27+
try {
28+
const [summaryData, activityData] = await Promise.all([
29+
groupService.userSummary(),
30+
groupService.userActivity(0, 5),
31+
]);
32+
setSummary(summaryData);
33+
setActivities(activityData.items);
34+
} catch (err) {
35+
console.error(err);
36+
toast('Failed to sync your vibes');
37+
} finally {
38+
setLoading(false);
39+
}
40+
}, [toast]);
41+
42+
React.useEffect(() => {
43+
void loadData();
44+
}, [loadData]);
2045

2146
const quickActions = [
22-
{ label: 'Add Expense', icon: 'plus-circle-outline' as const, color: colors.primary, onPress: () => navigation.navigate('AddExpense', { groupId: groups[0]?.id ?? '' }) },
23-
{ label: 'Groups', icon: 'account-group-outline' as const, color: colors.accent, onPress: () => navigation.navigate('Groups') },
24-
{ label: 'Settle Up', icon: 'handshake-outline' as const, color: colors.success, onPress: () => navigation.navigate('SettleUp', { groupId: groups[0]?.id ?? '' }) },
47+
{ label: 'Split it', icon: 'plus-circle-outline' as const, color: colors.primary, onPress: () => navigation.navigate('AddExpense', { groupId: groups[0]?.id ?? '' }) },
48+
{ label: 'Squads', icon: 'account-group-outline' as const, color: colors.accent, onPress: () => navigation.navigate('Groups') },
49+
{ label: 'Clear Air', icon: 'handshake-outline' as const, color: colors.success, onPress: () => navigation.navigate('SettleUp', { groupId: groups[0]?.id ?? '' }) },
2550
];
2651

52+
const totalBalance = Number(summary?.totalBalanceCents ?? 0) / 100;
53+
const balanceLabel = totalBalance >= 0 ? 'Securing the bag' : 'Lowkey in debt';
54+
const balanceVariant = totalBalance >= 0 ? 'success' : 'danger';
55+
const balanceIcon = totalBalance >= 0 ? 'trending-up' : 'trending-down';
56+
2757
return (
2858
<ScrollView
2959
style={{ flex: 1, backgroundColor: colors.background }}
@@ -33,8 +63,8 @@ export function HomeScreen({ navigation }: { navigation: any }) {
3363
{/* Header */}
3464
<View style={styles.header}>
3565
<View>
36-
<Text style={[typography.bodyMedium, { color: colors.text_secondary }]}>Welcome back,</Text>
37-
<Text style={[typography.h2, { color: colors.text_primary, marginTop: 2 }]}>{user?.name ?? 'Friend'}</Text>
66+
<Text style={[typography.bodyMedium, { color: colors.text_secondary }]}>Yo, welcome back ✌️</Text>
67+
<Text style={[typography.h2, { color: colors.text_primary, marginTop: 2 }]}>{user?.name ?? 'Bestie'}</Text>
3868
</View>
3969
<TouchableOpacity onPress={() => navigation.navigate('Profile')}>
4070
<Avatar name={user?.name ?? 'U'} size={48} />
@@ -44,16 +74,16 @@ export function HomeScreen({ navigation }: { navigation: any }) {
4474
{/* Balance Summary */}
4575
<Animated.View entering={FadeInDown.duration(400)} style={styles.summarySection}>
4676
<BalanceCard
47-
title="Total Balance"
48-
amount="$420.69"
49-
subtitle="You are owed"
50-
variant="success"
51-
icon="trending-up"
77+
title="The Bag 💰"
78+
amount={`$${Math.abs(totalBalance).toFixed(2)}`}
79+
subtitle={balanceLabel}
80+
variant={balanceVariant}
81+
icon={balanceIcon}
5282
/>
5383
</Animated.View>
5484

5585
{/* Quick Actions */}
56-
<SectionHeader title="Quick Actions" />
86+
<SectionHeader title="Fast Moves" />
5787
<View style={styles.quickActions}>
5888
{quickActions.map((action, i) => (
5989
<Animated.View
@@ -77,25 +107,31 @@ export function HomeScreen({ navigation }: { navigation: any }) {
77107

78108
{/* Recent Activity */}
79109
<SectionHeader
80-
title="Recent Activity"
110+
title="The Tea ☕"
81111
action="See all"
82-
onActionPress={() => {}}
112+
onActionPress={() => navigation.navigate('Activity')}
83113
/>
84114
<View style={styles.activityList}>
85-
{[1, 2, 3].map((item, i) => (
86-
<Animated.View
87-
key={item}
88-
entering={FadeInDown.duration(400).delay(400 + i * 100)}
89-
>
90-
<ActivityItem
91-
title="Dinner at Joe's"
92-
subtitle="Paid by you in Trip to Bali"
93-
amount="$45.00"
94-
date="2h ago"
95-
icon="receipt"
96-
/>
97-
</Animated.View>
98-
))}
115+
{activities.length > 0 ? (
116+
activities.map((activity, i) => (
117+
<Animated.View
118+
key={activity.id}
119+
entering={FadeInDown.duration(400).delay(400 + i * 100)}
120+
>
121+
<ActivityItem
122+
title={activity.type.replace('_', ' ')}
123+
subtitle={`${activity.actorUserId} in group`}
124+
amount={activity.metadata?.totalAmountCents ? `$${(Number(activity.metadata.totalAmountCents) / 100).toFixed(2)}` : undefined}
125+
date={new Date(activity.createdAt).toLocaleDateString()}
126+
icon="receipt"
127+
/>
128+
</Animated.View>
129+
))
130+
) : (
131+
<Text style={[typography.bodyMedium, { color: colors.text_secondary, textAlign: 'center', marginTop: spacing.lg }]}>
132+
No tea to spill yet.
133+
</Text>
134+
)}
99135
</View>
100136
</ScrollView>
101137
);

apps/mobile/app/screens/LoginScreen.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ export function LoginScreen({ navigation }: { navigation: { navigate: (route: st
4444
</View>
4545
<Text style={[typography.h1, { color: colors.text_primary, marginTop: spacing.lg }]}>FairShare</Text>
4646
<Text style={[typography.bodyMedium, { color: colors.text_secondary, marginTop: spacing.xs }]}>
47-
Sign in to your royal dashboard
47+
Secure the vibes, sign in
4848
</Text>
4949
</View>
5050

0 commit comments

Comments
 (0)