Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
58 changes: 52 additions & 6 deletions frontend/components/group/group-members.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
import { Button } from "@/components/ui/button";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import {
CheckCircle2,
Clock,
Expand All @@ -19,6 +20,7 @@ import { usePoolData } from "@/lib/data-layer/PoolDataProvider";
import { useOptimisticTransactions } from "@/hooks/useOptimisticTransactions";
import { RotationalPoolState, fetchReputation, type ReputationScore } from "@/hooks/useJointSaveContracts";
import { useToast } from "@/hooks/use-toast";
import { countPendingMembers, filterPendingMembers } from "@/lib/member-filters";

interface Member {
id: string;
Expand All @@ -28,12 +30,21 @@ interface Member {
joined_at: string;
}


interface GroupMembersProps {
groupId: string;
contractAddress?: string;
poolType?: "rotational" | "target" | "flexible";
}

// Status-coded avatar tint so each member's deposit status reads at a glance,
// matching the per-row status icon colors (green=paid, yellow=pending, red=late).
const statusAvatarClass: Record<Member["status"], string> = {
paid: "bg-primary/10 text-primary",
pending: "bg-yellow-500/10 text-yellow-800 dark:text-yellow-300",
late: "bg-destructive/10 text-destructive",
};

export function GroupMembers({
groupId,
contractAddress,
Expand All @@ -55,6 +66,7 @@ export function GroupMembers({

const [reputations, setReputations] = useState<Record<string, ReputationScore>>({});
const [copiedAddress, setCopiedAddress] = useState<string | null>(null);
const [showPendingOnly, setShowPendingOnly] = useState(false);

const handleCopyMemberAddress = async (address: string) => {
try {
Expand Down Expand Up @@ -102,6 +114,10 @@ export function GroupMembers({
optimisticState.pendingTx.type === "trigger_payout";
const nextRecipient = getNextPayoutRecipient();

// Client-side "pending only" view derived from data already on the page (no fetching).
const pendingCount = countPendingMembers(members);
const visibleMembers = showPendingOnly ? filterPendingMembers(members) : members;

if (isLoading && members.length === 0) {
return (
<Card className="p-6" aria-label="Loading members">
Expand All @@ -128,14 +144,44 @@ export function GroupMembers({

return (
<Card className="p-6">
<h3 className="text-lg font-semibold mb-4">Members ({members.length})</h3>
<div className="flex flex-col gap-3 mb-4 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-2">
<h3 className="text-lg font-semibold">Members ({members.length})</h3>
{members.length > 0 && (
<Badge variant="secondary" className="text-xs font-normal whitespace-nowrap tabular-nums">
{pendingCount} pending
</Badge>
)}
</div>
{members.length > 0 && (
<ToggleGroup
type="single"
variant="outline"
size="sm"
value={showPendingOnly ? "pending" : "all"}
onValueChange={(v) => setShowPendingOnly(v === "pending")}
aria-label="Filter members by deposit status"
>
<ToggleGroupItem value="all" aria-label="Show all members">
Show all
</ToggleGroupItem>
<ToggleGroupItem value="pending" aria-label="Show pending members only">
Pending only
</ToggleGroupItem>
</ToggleGroup>
)}
</div>
{members.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-4">
No members yet
</p>
) : visibleMembers.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-4">
Everyone has deposited
</p>
) : (
<div className="space-y-3">
{members.map((member) => {
{visibleMembers.map((member) => {
const isPendingPayout =
isPayoutPending &&
member.member_address.toUpperCase() === nextRecipient;
Expand All @@ -150,7 +196,7 @@ export function GroupMembers({
>
<div className="flex items-center gap-3">
<Avatar className="h-10 w-10">
<AvatarFallback className="bg-primary/10 text-primary">
<AvatarFallback className={statusAvatarClass[member.status]}>
{member.member_address.slice(2, 4).toUpperCase()}
</AvatarFallback>
</Avatar>
Expand Down Expand Up @@ -190,13 +236,13 @@ export function GroupMembers({
{!isPendingPayout && (
<>
{member.status === "paid" && (
<CheckCircle2 className="h-4 w-4 text-primary" />
<CheckCircle2 className="h-4 w-4 text-primary" role="img" aria-label="Paid" />
)}
{member.status === "pending" && (
<Clock className="h-4 w-4 text-muted-foreground" />
<Clock className="h-4 w-4 text-yellow-700 dark:text-yellow-400" role="img" aria-label="Pending" />
)}
{member.status === "late" && (
<XCircle className="h-4 w-4 text-destructive" />
<XCircle className="h-4 w-4 text-destructive" role="img" aria-label="Late" />
)}
</>
)}
Expand Down
44 changes: 44 additions & 0 deletions frontend/lib/member-filters.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
// Unit tests for the pure member deposit-status filter helpers.
import { test } from 'node:test';
import assert from 'node:assert';
import {
isPendingMember,
countPendingMembers,
filterPendingMembers,
} from './member-filters';

// Build a minimal member with a given status.
function member(status: string) {
return { status };
}

test('countPendingMembers - empty array is zero', () => {
assert.strictEqual(countPendingMembers([]), 0);
});

test('countPendingMembers - counts only pending, ignoring paid and late', () => {
const members = [member('pending'), member('paid'), member('late'), member('pending')];
assert.strictEqual(countPendingMembers(members), 2);
});

test('countPendingMembers - all paid is zero', () => {
const members = [member('paid'), member('paid')];
assert.strictEqual(countPendingMembers(members), 0);
});

test('filterPendingMembers - empty array returns empty', () => {
assert.deepStrictEqual(filterPendingMembers([]), []);
});

test('filterPendingMembers - returns only the pending members', () => {
const pendingA = member('pending');
const pendingB = member('pending');
const members = [pendingA, member('paid'), member('late'), pendingB];
assert.deepStrictEqual(filterPendingMembers(members), [pendingA, pendingB]);
});

test('isPendingMember - true only for pending status', () => {
assert.strictEqual(isPendingMember(member('pending')), true);
assert.strictEqual(isPendingMember(member('paid')), false);
assert.strictEqual(isPendingMember(member('late')), false);
});
28 changes: 28 additions & 0 deletions frontend/lib/member-filters.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
// Pure, client-side helpers for filtering pool members by deposit status.
// They mirror the status the member list already displays (the Clock icon),
// so the filter is just a view over data already on the page — no new fetching.

/** Minimal shape needed to decide pending-ness; keeps helpers decoupled from the full Member type. */
export interface MemberStatusLike {
status: string;
}

/**
* A member is "pending" (has not deposited yet) when status === "pending".
* This mirrors the displayed status; `late` is intentionally excluded to match the
* issue's acceptance criteria ("only pending members"). If product later wants late
* members included, this predicate is the only line to change.
*/
export function isPendingMember(member: MemberStatusLike): boolean {
return member.status === "pending";
}

/** Count of members still pending a deposit. */
export function countPendingMembers(members: MemberStatusLike[]): number {
return members.filter(isPendingMember).length;
}

/** Subset of members still pending a deposit, preserving the input element type. */
export function filterPendingMembers<T extends MemberStatusLike>(members: T[]): T[] {
return members.filter(isPendingMember);
}
2 changes: 1 addition & 1 deletion frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
"dev": "next dev --webpack",
"lint": "next lint",
"start": "next start",
"test:unit": "tsx --test lib/csv-export.test.ts lib/analytics.test.ts lib/pool-health.test.ts lib/form-validation.test.ts hooks/use-keyboard-shortcuts.test.ts app/api/admin/audit-log/route.test.ts app/api/admin/actions/route.test.ts",
"test:unit": "tsx --test lib/csv-export.test.ts lib/analytics.test.ts lib/pool-health.test.ts lib/form-validation.test.ts lib/member-filters.test.ts hooks/use-keyboard-shortcuts.test.ts app/api/admin/audit-log/route.test.ts app/api/admin/actions/route.test.ts",
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui",
"test:e2e:report": "playwright show-report"
Expand Down
Loading