Skip to content

Commit cb94a1d

Browse files
web: show settlement reminder history
1 parent 02583f3 commit cb94a1d

4 files changed

Lines changed: 120 additions & 17 deletions

File tree

apps/web/app/dashboard/groups/[groupId]/settle/page.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { GroupDto, GroupMemberSummaryDto, SimplifySuggestionDto } from '@fairshare/shared-types';
1+
import { ActivityDto, GroupDto, GroupMemberSummaryDto, SimplifySuggestionDto } from '@fairshare/shared-types';
22
import { notFound } from 'next/navigation';
33
import { DashboardLayout } from '../../../../../src/components/layout';
44
import { SettlementList } from '../../../../../src/components/groups';
@@ -8,10 +8,11 @@ export default async function SettleGroupPage({ params }: { params: { groupId: s
88
const { groupId } = params;
99

1010
try {
11-
const [group, members, suggestions] = await Promise.all([
11+
const [group, members, suggestions, activity] = await Promise.all([
1212
backendFetch<GroupDto>(`/groups/${groupId}`),
1313
backendFetch<GroupMemberSummaryDto[]>(`/groups/${groupId}/members`),
1414
backendFetch<SimplifySuggestionDto[]>(`/groups/${groupId}/simplify`),
15+
backendFetch<{ items: ActivityDto[]; nextCursor: number | null }>(`/activity/group/${groupId}?cursor=0&limit=20`),
1516
]);
1617

1718
const memberLookup = members.reduce<Record<string, { name: string; email: string }>>((acc, member) => {
@@ -35,6 +36,7 @@ export default async function SettleGroupPage({ params }: { params: { groupId: s
3536
currency={group.currency}
3637
suggestions={suggestions}
3738
memberLookup={memberLookup}
39+
initialReminderActivity={activity.items.filter((event) => event.type === 'settlement_reminder')}
3840
/>
3941
</div>
4042
</DashboardLayout>

apps/web/src/components/activity/ActivityFeed.tsx

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import type { JSX } from 'react';
44
import { useEffect, useMemo, useState } from 'react';
55
import { ActivityDto, GroupDto } from '@fairshare/shared-types';
6-
import { ActivitySquare, BadgeCheck, Clock3, Mail, Receipt, RefreshCw, UsersRound } from 'lucide-react';
6+
import { ActivitySquare, BadgeCheck, BellRing, Clock3, Mail, Receipt, RefreshCw, UsersRound } from 'lucide-react';
77
import { useToast } from '../ui/Toaster';
88

99
type ActivityResponse = { items: ActivityDto[]; nextCursor: number | null };
@@ -13,6 +13,7 @@ const iconByType: Record<ActivityDto['type'], JSX.Element> = {
1313
expense_updated: <RefreshCw className="w-4 h-4" />,
1414
expense_deleted: <ActivitySquare className="w-4 h-4" />,
1515
settlement_created: <BadgeCheck className="w-4 h-4" />,
16+
settlement_reminder: <BellRing className="w-4 h-4" />,
1617
member_joined: <UsersRound className="w-4 h-4" />,
1718
member_invited: <Mail className="w-4 h-4" />,
1819
};
@@ -22,6 +23,7 @@ const accentByType: Record<ActivityDto['type'], string> = {
2223
expense_updated: '#F59E0B',
2324
expense_deleted: '#EF4444',
2425
settlement_created: '#22C55E',
26+
settlement_reminder: '#8B5CF6',
2527
member_joined: '#14B8A6',
2628
member_invited: '#EC4899',
2729
};
@@ -37,6 +39,13 @@ function labelFor(activity: ActivityDto, userNameMap: Record<string, string>): s
3739
return `${actor} deleted an expense`;
3840
case 'settlement_created':
3941
return `${actor} recorded a settlement`;
42+
case 'settlement_reminder': {
43+
const payerId = typeof activity.metadata?.payerId === 'string' ? activity.metadata.payerId : '';
44+
const receiverId = typeof activity.metadata?.receiverId === 'string' ? activity.metadata.receiverId : '';
45+
const payer = userNameMap[payerId] ?? payerId.slice(0, 8);
46+
const receiver = userNameMap[receiverId] ?? receiverId.slice(0, 8);
47+
return `${actor} reminded ${payer} to pay ${receiver}`;
48+
}
4049
case 'member_joined':
4150
return `${actor} joined the group`;
4251
case 'member_invited':
@@ -217,7 +226,7 @@ export function ActivityFeed({
217226
className="w-full rounded-xl border border-[var(--fs-border)] bg-[var(--fs-card)] px-4 py-3 text-sm font-bold text-[var(--fs-text-primary)] hover:border-[var(--fs-primary)] transition-colors"
218227
disabled={loadingMore}
219228
>
220-
{loadingMore ? 'Loading more' : 'Load more'}
229+
{loadingMore ? 'Loading more...' : 'Load more'}
221230
</button>
222231
) : null}
223232
</div>

apps/web/src/components/groups/SettlementList.tsx

Lines changed: 99 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
'use client';
22

33
import { useMemo, useState } from 'react';
4-
import { SimplifySuggestionDto } from '@fairshare/shared-types';
5-
import { BellRing, CheckCircle2 } from 'lucide-react';
4+
import { ActivityDto, SimplifySuggestionDto } from '@fairshare/shared-types';
5+
import { BellRing, CheckCircle2, Clock3 } from 'lucide-react';
66
import { useRouter } from 'next/navigation';
77
import { createSettlementAction, remindSettlementAction } from '../../lib/actions';
88
import { useToast } from '../ui/Toaster';
@@ -12,14 +12,30 @@ type SettlementListProps = {
1212
currency: string;
1313
suggestions: SimplifySuggestionDto[];
1414
memberLookup: Record<string, { name: string; email: string }>;
15+
initialReminderActivity: ActivityDto[];
1516
};
1617

17-
export function SettlementList({ groupId, currency, suggestions, memberLookup }: SettlementListProps) {
18+
const suggestionKey = (fromUserId: string, toUserId: string, amountCents: string) => `${fromUserId}-${toUserId}-${amountCents}`;
19+
20+
function timeAgo(iso: string): string {
21+
const now = Date.now();
22+
const diff = Math.max(1, Math.floor((now - new Date(iso).getTime()) / 1000));
23+
if (diff < 60) return `${diff}s ago`;
24+
const minutes = Math.floor(diff / 60);
25+
if (minutes < 60) return `${minutes}m ago`;
26+
const hours = Math.floor(minutes / 60);
27+
if (hours < 24) return `${hours}h ago`;
28+
const days = Math.floor(hours / 24);
29+
return `${days}d ago`;
30+
}
31+
32+
export function SettlementList({ groupId, currency, suggestions, memberLookup, initialReminderActivity }: SettlementListProps) {
1833
const [pendingId, setPendingId] = useState<string | null>(null);
1934
const [reminderId, setReminderId] = useState<string | null>(null);
2035
const [query, setQuery] = useState('');
2136
const [fromUserId, setFromUserId] = useState('');
2237
const [toUserId, setToUserId] = useState('');
38+
const [reminderEvents, setReminderEvents] = useState<ActivityDto[]>(initialReminderActivity);
2339
const { toast } = useToast();
2440
const router = useRouter();
2541

@@ -50,17 +66,47 @@ export function SettlementList({ groupId, currency, suggestions, memberLookup }:
5066
return true;
5167
}
5268

53-
const haystack = [
54-
labelForUser(item.fromUserId),
55-
labelForUser(item.toUserId),
56-
formatAmount(item.amountCents),
57-
]
69+
const haystack = [labelForUser(item.fromUserId), labelForUser(item.toUserId), formatAmount(item.amountCents)]
5870
.join(' ')
5971
.toLowerCase();
6072

6173
return haystack.includes(normalizedQuery);
6274
});
63-
}, [fromUserId, query, suggestions, toUserId]);
75+
}, [fromUserId, query, suggestions, toUserId, memberLookup]);
76+
77+
const latestReminderBySuggestion = useMemo(() => {
78+
const entries = new Map<string, ActivityDto>();
79+
80+
reminderEvents.forEach((event) => {
81+
if (event.type !== 'settlement_reminder') {
82+
return;
83+
}
84+
85+
const payerId = typeof event.metadata?.payerId === 'string' ? event.metadata.payerId : null;
86+
const receiverId = typeof event.metadata?.receiverId === 'string' ? event.metadata.receiverId : null;
87+
const amountCents = typeof event.metadata?.amountCents === 'string' ? event.metadata.amountCents : null;
88+
if (!payerId || !receiverId || !amountCents) {
89+
return;
90+
}
91+
92+
const key = suggestionKey(payerId, receiverId, amountCents);
93+
const existing = entries.get(key);
94+
if (!existing || new Date(event.createdAt).getTime() > new Date(existing.createdAt).getTime()) {
95+
entries.set(key, event);
96+
}
97+
});
98+
99+
return entries;
100+
}, [reminderEvents]);
101+
102+
const recentReminders = useMemo(
103+
() =>
104+
reminderEvents
105+
.filter((event) => event.type === 'settlement_reminder')
106+
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
107+
.slice(0, 4),
108+
[reminderEvents],
109+
);
64110

65111
const resetFilters = () => {
66112
setQuery('');
@@ -69,7 +115,7 @@ export function SettlementList({ groupId, currency, suggestions, memberLookup }:
69115
};
70116

71117
const markSettled = async (suggestion: SimplifySuggestionDto) => {
72-
setPendingId(`${suggestion.fromUserId}-${suggestion.toUserId}-${suggestion.amountCents}`);
118+
setPendingId(suggestionKey(suggestion.fromUserId, suggestion.toUserId, suggestion.amountCents));
73119
const result = await createSettlementAction(groupId, {
74120
payerId: suggestion.fromUserId,
75121
receiverId: suggestion.toUserId,
@@ -88,7 +134,7 @@ export function SettlementList({ groupId, currency, suggestions, memberLookup }:
88134
};
89135

90136
const sendReminder = async (suggestion: SimplifySuggestionDto) => {
91-
const key = `${suggestion.fromUserId}-${suggestion.toUserId}-${suggestion.amountCents}`;
137+
const key = suggestionKey(suggestion.fromUserId, suggestion.toUserId, suggestion.amountCents);
92138
setReminderId(key);
93139
const result = await remindSettlementAction(groupId, {
94140
payerId: suggestion.fromUserId,
@@ -102,6 +148,9 @@ export function SettlementList({ groupId, currency, suggestions, memberLookup }:
102148
return;
103149
}
104150

151+
if ('activity' in result && result.activity) {
152+
setReminderEvents((prev) => [result.activity, ...prev.filter((event) => event.id !== result.activity.id)]);
153+
}
105154
toast(`Reminder sent to ${labelForUser(suggestion.fromUserId)}`);
106155
setReminderId(null);
107156
};
@@ -156,6 +205,36 @@ export function SettlementList({ groupId, currency, suggestions, memberLookup }:
156205
</div>
157206
</div>
158207

208+
{recentReminders.length > 0 ? (
209+
<div className="rounded-3xl border border-[var(--fs-border)] bg-[var(--fs-card)] p-5 shadow-[var(--fs-shadow-soft)]">
210+
<div className="flex items-center gap-2">
211+
<BellRing className="h-4 w-4 text-[var(--fs-primary)]" />
212+
<h3 className="text-base font-extrabold text-[var(--fs-text-primary)]">Recent reminders</h3>
213+
</div>
214+
<div className="mt-3 space-y-2">
215+
{recentReminders.map((event) => {
216+
const payerId = typeof event.metadata?.payerId === 'string' ? event.metadata.payerId : '';
217+
const receiverId = typeof event.metadata?.receiverId === 'string' ? event.metadata.receiverId : '';
218+
const amountCents = typeof event.metadata?.amountCents === 'string' ? event.metadata.amountCents : '0';
219+
return (
220+
<div key={event.id} className="rounded-2xl border border-[var(--fs-border)] bg-[var(--fs-background)]/70 px-4 py-3">
221+
<p className="text-sm font-semibold text-[var(--fs-text-primary)]">
222+
{labelForUser(event.actorUserId)} reminded {labelForUser(payerId)} to pay {labelForUser(receiverId)}
223+
</p>
224+
<div className="mt-1 flex flex-wrap items-center gap-2 text-[11px] font-medium text-[var(--fs-text-muted)]">
225+
<span>{formatAmount(amountCents)}</span>
226+
<span className="inline-flex items-center gap-1">
227+
<Clock3 className="h-3 w-3" />
228+
{timeAgo(event.createdAt)}
229+
</span>
230+
</div>
231+
</div>
232+
);
233+
})}
234+
</div>
235+
</div>
236+
) : null}
237+
159238
{filteredSuggestions.length === 0 ? (
160239
<div className="rounded-3xl border border-[var(--fs-border)] bg-[var(--fs-card)] p-8 text-center shadow-[var(--fs-shadow-soft)]">
161240
<p className="text-lg font-bold text-[var(--fs-text-primary)] mb-1">
@@ -167,9 +246,11 @@ export function SettlementList({ groupId, currency, suggestions, memberLookup }:
167246
</div>
168247
) : (
169248
filteredSuggestions.map((item) => {
170-
const key = `${item.fromUserId}-${item.toUserId}-${item.amountCents}`;
249+
const key = suggestionKey(item.fromUserId, item.toUserId, item.amountCents);
171250
const pending = pendingId === key;
172251
const reminding = reminderId === key;
252+
const latestReminder = latestReminderBySuggestion.get(key);
253+
173254
return (
174255
<div
175256
key={key}
@@ -181,6 +262,11 @@ export function SettlementList({ groupId, currency, suggestions, memberLookup }:
181262
{labelForUser(item.toUserId)}
182263
</p>
183264
<p className="text-[11px] font-medium text-[var(--fs-text-muted)]">{formatAmount(item.amountCents)}</p>
265+
{latestReminder ? (
266+
<p className="mt-1 text-[11px] font-medium text-[var(--fs-text-muted)]">
267+
Last reminded {timeAgo(latestReminder.createdAt)} by {labelForUser(latestReminder.actorUserId)}
268+
</p>
269+
) : null}
184270
</div>
185271
<div className="flex w-full sm:w-auto items-center gap-2">
186272
<button
@@ -212,3 +298,4 @@ export function SettlementList({ groupId, currency, suggestions, memberLookup }:
212298
</div>
213299
);
214300
}
301+

apps/web/src/lib/actions.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
PresignedReceiptUrlResponseDto,
1010
RecurringExpenseDto,
1111
RemindSettlementRequestDto,
12+
RemindSettlementResponseDto,
1213
SettlementDto,
1314
UpdateExpenseRequestDto,
1415
UpdateGroupDefaultSplitRequestDto,
@@ -190,7 +191,7 @@ export async function remindSettlementAction(groupId: string, payload: RemindSet
190191
};
191192
}
192193

193-
return { success: true };
194+
return { ...(data as RemindSettlementResponseDto), success: true };
194195
}
195196

196197
export async function createReceiptUrlAction(expenseId: string, extension?: string) {
@@ -316,3 +317,7 @@ export async function deleteRecurringExpenseAction(recurringExpenseId: string) {
316317

317318
return { success: true };
318319
}
320+
321+
322+
323+

0 commit comments

Comments
 (0)