Skip to content

Commit 9edeb21

Browse files
mobile: show settlement reminder history
1 parent cb94a1d commit 9edeb21

3 files changed

Lines changed: 205 additions & 86 deletions

File tree

apps/mobile/app/screens/ActivityScreen.tsx

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ const iconByType: Record<ActivityType, keyof typeof MaterialCommunityIcons.glyph
1818
expense_updated: 'file-document-edit',
1919
expense_deleted: 'delete',
2020
settlement_created: 'cash',
21+
settlement_reminder: 'bell-ring-outline',
2122
member_joined: 'account-plus',
2223
member_invited: 'account-arrow-right',
2324
};
@@ -27,6 +28,7 @@ const colorByType: Record<ActivityType, string> = {
2728
expense_updated: '#F59E0B',
2829
expense_deleted: '#EF4444',
2930
settlement_created: '#22C55E',
31+
settlement_reminder: '#8B5CF6',
3032
member_joined: '#14B8A6',
3133
member_invited: '#EC4899',
3234
};
@@ -42,6 +44,11 @@ const actionText = (activity: ActivityDto): string => {
4244
return `${actor} deleted an expense`;
4345
case 'settlement_created':
4446
return `${actor} recorded a settlement`;
47+
case 'settlement_reminder': {
48+
const payerId = typeof activity.metadata?.payerId === 'string' ? activity.metadata.payerId : 'a member';
49+
const receiverId = typeof activity.metadata?.receiverId === 'string' ? activity.metadata.receiverId : 'another member';
50+
return `${actor} reminded ${payerId} to pay ${receiverId}`;
51+
}
4552
case 'member_joined':
4653
return `${actor} joined the group`;
4754
case 'member_invited':
@@ -81,7 +88,7 @@ export function ActivityScreen({ route }: { route?: { params?: { groupId?: strin
8188

8289
const loadFirstPage = React.useCallback(async () => {
8390
try {
84-
const data = groupId
91+
const data = groupId
8592
? await groupService.activity(groupId, 0, PAGE_SIZE)
8693
: await groupService.userActivity(0, PAGE_SIZE);
8794
setEvents(data.items);
@@ -137,7 +144,7 @@ export function ActivityScreen({ route }: { route?: { params?: { groupId?: strin
137144
<MaterialCommunityIcons name={iconByType[item.type] || 'information-outline'} size={20} color={iconColor} />
138145
</View>
139146
<View style={styles.content}>
140-
<Text style={[styles.title, { color: colors.text_primary }]} numberOfLines={1}>
147+
<Text style={[styles.title, { color: colors.text_primary }]} numberOfLines={2}>
141148
{actionText(item)}
142149
</Text>
143150
<Text style={[styles.time, { color: colors.text_secondary }]}>
@@ -228,4 +235,4 @@ const styles = StyleSheet.create({
228235
fontSize: 15,
229236
fontWeight: '700',
230237
},
231-
});
238+
});

apps/mobile/app/screens/SettleUpScreen.tsx

Lines changed: 193 additions & 82 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,38 @@
11
import React from 'react';
22
import { Linking, Modal, ScrollView, StyleSheet, TouchableOpacity, View } from 'react-native';
33
import { Button, Text, TextInput } from 'react-native-paper';
4-
import type { GroupMemberSummaryDto, SimplifySuggestionDto } from '@fairshare/shared-types';
4+
import type { ActivityDto, GroupMemberSummaryDto, SimplifySuggestionDto } from '@fairshare/shared-types';
55
import LottieView from 'lottie-react-native';
66
import { settlementService } from '../services/settlement.service';
77
import { groupService } from '../services/group.service';
88
import { useToastStore } from '../store/toastStore';
99
import { spacing } from '../theme/spacing';
1010

11+
const suggestionKey = (fromUserId: string, toUserId: string, amountCents: string) => `${fromUserId}-${toUserId}-${amountCents}`;
12+
13+
const relativeTime = (iso: string): string => {
14+
const now = Date.now();
15+
const timestamp = new Date(iso).getTime();
16+
const diffSec = Math.max(1, Math.floor((now - timestamp) / 1000));
17+
18+
if (diffSec < 60) return `${diffSec}s ago`;
19+
const diffMin = Math.floor(diffSec / 60);
20+
if (diffMin < 60) return `${diffMin}m ago`;
21+
const diffHour = Math.floor(diffMin / 60);
22+
if (diffHour < 24) return `${diffHour}h ago`;
23+
const diffDay = Math.floor(diffHour / 24);
24+
return `${diffDay}d ago`;
25+
};
26+
1127
export function SettleUpScreen({ route }: { route: { params: { groupId: string } } }) {
1228
const [suggestions, setSuggestions] = React.useState<SimplifySuggestionDto[]>([]);
1329
const [members, setMembers] = React.useState<GroupMemberSummaryDto[]>([]);
30+
const [reminderEvents, setReminderEvents] = React.useState<ActivityDto[]>([]);
1431
const [successOpen, setSuccessOpen] = React.useState(false);
1532
const [upiReceiver, setUpiReceiver] = React.useState('');
1633
const [upiName, setUpiName] = React.useState('FairShare Member');
1734
const [selectedIndex, setSelectedIndex] = React.useState<number | null>(null);
18-
const [remindingIndex, setRemindingIndex] = React.useState<number | null>(null);
35+
const [remindingKey, setRemindingKey] = React.useState<string | null>(null);
1936
const [query, setQuery] = React.useState('');
2037
const [fromUserId, setFromUserId] = React.useState('');
2138
const [toUserId, setToUserId] = React.useState('');
@@ -24,12 +41,14 @@ export function SettleUpScreen({ route }: { route: { params: { groupId: string }
2441
React.useEffect(() => {
2542
const load = async () => {
2643
try {
27-
const [nextSuggestions, nextMembers] = await Promise.all([
44+
const [nextSuggestions, nextMembers, activityData] = await Promise.all([
2845
groupService.simplify(route.params.groupId),
2946
groupService.members(route.params.groupId),
47+
groupService.activity(route.params.groupId, 0, 20),
3048
]);
3149
setSuggestions(nextSuggestions);
3250
setMembers(nextMembers);
51+
setReminderEvents(activityData.items.filter((event) => event.type === 'settlement_reminder'));
3352
} catch {
3453
toast('Failed to load suggestions');
3554
}
@@ -60,6 +79,35 @@ export function SettleUpScreen({ route }: { route: { params: { groupId: string }
6079
});
6180
}, [fromUserId, query, suggestions, toUserId, members]);
6281

82+
const latestReminderBySuggestion = React.useMemo(() => {
83+
const entries = new Map<string, ActivityDto>();
84+
85+
reminderEvents.forEach((event) => {
86+
const payerId = typeof event.metadata?.payerId === 'string' ? event.metadata.payerId : null;
87+
const receiverId = typeof event.metadata?.receiverId === 'string' ? event.metadata.receiverId : null;
88+
const amountCents = typeof event.metadata?.amountCents === 'string' ? event.metadata.amountCents : null;
89+
if (!payerId || !receiverId || !amountCents) {
90+
return;
91+
}
92+
93+
const key = suggestionKey(payerId, receiverId, amountCents);
94+
const existing = entries.get(key);
95+
if (!existing || new Date(event.createdAt).getTime() > new Date(existing.createdAt).getTime()) {
96+
entries.set(key, event);
97+
}
98+
});
99+
100+
return entries;
101+
}, [reminderEvents]);
102+
103+
const recentReminders = React.useMemo(
104+
() =>
105+
[...reminderEvents]
106+
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
107+
.slice(0, 4),
108+
[reminderEvents],
109+
);
110+
63111
const resetFilters = () => {
64112
setQuery('');
65113
setFromUserId('');
@@ -129,93 +177,126 @@ export function SettleUpScreen({ route }: { route: { params: { groupId: string }
129177
</View>
130178
</View>
131179

180+
{recentReminders.length > 0 ? (
181+
<View style={styles.historyCard}>
182+
<Text style={styles.historyTitle}>Recent reminders</Text>
183+
{recentReminders.map((event) => {
184+
const payerId = typeof event.metadata?.payerId === 'string' ? event.metadata.payerId : '';
185+
const receiverId = typeof event.metadata?.receiverId === 'string' ? event.metadata.receiverId : '';
186+
const amountCents = typeof event.metadata?.amountCents === 'string' ? event.metadata.amountCents : '0';
187+
188+
return (
189+
<View key={event.id} style={styles.historyItem}>
190+
<Text style={styles.historyText}>
191+
{labelForUser(event.actorUserId)} reminded {labelForUser(payerId)} to pay {labelForUser(receiverId)}
192+
</Text>
193+
<Text style={styles.historyMeta}>
194+
${(Number(amountCents) / 100).toFixed(2)} • {relativeTime(event.createdAt)}
195+
</Text>
196+
</View>
197+
);
198+
})}
199+
</View>
200+
) : null}
201+
132202
{filteredSuggestions.length === 0 ? (
133203
<View style={styles.emptyCard}>
134204
<Text style={styles.title}>{suggestions.length === 0 ? 'All clear' : 'No matching settlements'}</Text>
135205
<Text style={styles.amount}>{suggestions.length === 0 ? 'No suggested settlements right now.' : 'Try a different payer, receiver, or search query.'}</Text>
136206
</View>
137207
) : null}
138208

139-
{filteredSuggestions.map((item, index) => (
140-
<View key={`${item.fromUserId}-${item.toUserId}-${index}`} style={styles.card}>
141-
<Text style={styles.title}>
142-
{labelForUser(item.fromUserId)} pays {labelForUser(item.toUserId)}
143-
</Text>
144-
<Text style={styles.amount}>${(Number(item.amountCents) / 100).toFixed(2)}</Text>
145-
<View style={styles.actionRow}>
146-
<Button
147-
mode="outlined"
148-
style={styles.actionButton}
149-
loading={remindingIndex === index}
150-
disabled={remindingIndex === index}
151-
onPress={async () => {
152-
try {
153-
setRemindingIndex(index);
154-
await groupService.remindSettlement(route.params.groupId, {
155-
payerId: item.fromUserId,
156-
receiverId: item.toUserId,
157-
amountCents: item.amountCents,
158-
});
159-
toast(`Reminder sent to ${labelForUser(item.fromUserId)}`);
160-
} catch {
161-
toast('Failed to send reminder');
162-
} finally {
163-
setRemindingIndex(null);
164-
}
165-
}}
166-
>
167-
Remind
168-
</Button>
169-
<Button
170-
mode="outlined"
171-
style={styles.actionButton}
172-
onPress={async () => {
173-
if (!upiReceiver.trim()) {
174-
toast('Enter receiver UPI ID first');
175-
return;
176-
}
177-
const amount = (Number(item.amountCents) / 100).toFixed(2);
178-
const upiLink = `upi://pay?pa=${encodeURIComponent(upiReceiver)}&pn=${encodeURIComponent(
179-
upiName || 'FairShare Member',
180-
)}&am=${encodeURIComponent(amount)}&cu=INR`;
181-
const supported = await Linking.canOpenURL(upiLink);
182-
if (!supported) {
183-
toast('No UPI app found on this device');
184-
return;
185-
}
186-
setSelectedIndex(index);
187-
await Linking.openURL(upiLink);
188-
}}
189-
>
190-
Pay via UPI
191-
</Button>
209+
{filteredSuggestions.map((item, index) => {
210+
const key = suggestionKey(item.fromUserId, item.toUserId, item.amountCents);
211+
const latestReminder = latestReminderBySuggestion.get(key);
212+
213+
return (
214+
<View key={`${key}-${index}`} style={styles.card}>
215+
<Text style={styles.title}>
216+
{labelForUser(item.fromUserId)} pays {labelForUser(item.toUserId)}
217+
</Text>
218+
<Text style={styles.amount}>${(Number(item.amountCents) / 100).toFixed(2)}</Text>
219+
{latestReminder ? (
220+
<Text style={styles.reminderStatus}>
221+
Last reminded {relativeTime(latestReminder.createdAt)} by {labelForUser(latestReminder.actorUserId)}
222+
</Text>
223+
) : null}
224+
<View style={styles.actionRow}>
225+
<Button
226+
mode="outlined"
227+
style={styles.actionButton}
228+
loading={remindingKey === key}
229+
disabled={remindingKey === key}
230+
onPress={async () => {
231+
try {
232+
setRemindingKey(key);
233+
const result = await groupService.remindSettlement(route.params.groupId, {
234+
payerId: item.fromUserId,
235+
receiverId: item.toUserId,
236+
amountCents: item.amountCents,
237+
});
238+
setReminderEvents((prev) => [result.activity, ...prev.filter((event) => event.id !== result.activity.id)]);
239+
toast(`Reminder sent to ${labelForUser(item.fromUserId)}`);
240+
} catch {
241+
toast('Failed to send reminder');
242+
} finally {
243+
setRemindingKey(null);
244+
}
245+
}}
246+
>
247+
Remind
248+
</Button>
249+
<Button
250+
mode="outlined"
251+
style={styles.actionButton}
252+
onPress={async () => {
253+
if (!upiReceiver.trim()) {
254+
toast('Enter receiver UPI ID first');
255+
return;
256+
}
257+
const amount = (Number(item.amountCents) / 100).toFixed(2);
258+
const upiLink = `upi://pay?pa=${encodeURIComponent(upiReceiver)}&pn=${encodeURIComponent(
259+
upiName || 'FairShare Member',
260+
)}&am=${encodeURIComponent(amount)}&cu=INR`;
261+
const supported = await Linking.canOpenURL(upiLink);
262+
if (!supported) {
263+
toast('No UPI app found on this device');
264+
return;
265+
}
266+
setSelectedIndex(index);
267+
await Linking.openURL(upiLink);
268+
}}
269+
>
270+
Pay via UPI
271+
</Button>
272+
</View>
273+
{selectedIndex === index ? (
274+
<Button
275+
mode="contained"
276+
style={{ marginTop: 8 }}
277+
onPress={async () => {
278+
try {
279+
await settlementService.create(route.params.groupId, {
280+
payerId: item.fromUserId,
281+
receiverId: item.toUserId,
282+
amountCents: item.amountCents,
283+
});
284+
setSuccessOpen(true);
285+
setTimeout(() => {
286+
setSuccessOpen(false);
287+
toast('Settlement recorded');
288+
}, 700);
289+
} catch {
290+
toast('Failed to mark settlement');
291+
}
292+
}}
293+
>
294+
Mark as paid
295+
</Button>
296+
) : null}
192297
</View>
193-
{selectedIndex === index ? (
194-
<Button
195-
mode="contained"
196-
style={{ marginTop: 8 }}
197-
onPress={async () => {
198-
try {
199-
await settlementService.create(route.params.groupId, {
200-
payerId: item.fromUserId,
201-
receiverId: item.toUserId,
202-
amountCents: item.amountCents,
203-
});
204-
setSuccessOpen(true);
205-
setTimeout(() => {
206-
setSuccessOpen(false);
207-
toast('Settlement recorded');
208-
}, 700);
209-
} catch {
210-
toast('Failed to mark settlement');
211-
}
212-
}}
213-
>
214-
Mark as paid
215-
</Button>
216-
) : null}
217-
</View>
218-
))}
298+
);
299+
})}
219300
</ScrollView>
220301

221302
<Modal visible={successOpen} transparent>
@@ -277,6 +358,31 @@ const styles = StyleSheet.create({
277358
color: '#6B7280',
278359
fontWeight: '600',
279360
},
361+
historyCard: {
362+
marginBottom: spacing.md,
363+
padding: spacing.md,
364+
borderRadius: 16,
365+
borderWidth: 1,
366+
borderColor: 'rgba(148, 163, 184, 0.24)',
367+
gap: spacing.sm,
368+
},
369+
historyTitle: {
370+
fontSize: 15,
371+
fontWeight: '800',
372+
color: '#111827',
373+
},
374+
historyItem: {
375+
gap: 2,
376+
},
377+
historyText: {
378+
fontSize: 13,
379+
fontWeight: '600',
380+
color: '#111827',
381+
},
382+
historyMeta: {
383+
fontSize: 12,
384+
color: '#6B7280',
385+
},
280386
emptyCard: {
281387
marginBottom: spacing.md,
282388
padding: spacing.lg,
@@ -301,6 +407,11 @@ const styles = StyleSheet.create({
301407
fontSize: 14,
302408
color: '#6B7280',
303409
},
410+
reminderStatus: {
411+
fontSize: 12,
412+
color: '#6B7280',
413+
fontWeight: '600',
414+
},
304415
actionRow: {
305416
flexDirection: 'row',
306417
gap: spacing.sm,

0 commit comments

Comments
 (0)