Skip to content
Open
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
14 changes: 12 additions & 2 deletions src/app/api/groups/[id]/leaderboard/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,16 +34,23 @@ export async function GET(
const invoices = await getGroupInvoices(groupId);

// Calculate member stats
const memberStats = new Map<string, { owed: bigint; received: bigint }>();
const memberStats = new Map<
string,
{ owed: bigint; received: bigint; invoiceCount: number; streak: number }
>();

for (const invoice of invoices) {
for (const recipient of invoice.recipients) {
if (!memberStats.has(recipient.address)) {
memberStats.set(recipient.address, { owed: 0n, received: 0n });
memberStats.set(recipient.address, { owed: 0n, received: 0n, invoiceCount: 0, streak: 0 });
}

const stats = memberStats.get(recipient.address)!;
stats.owed += recipient.amount;
stats.invoiceCount += 1;
if (invoice.status === 'paid') {
stats.streak += 1;
}

// Calculate received amount based on payment ratio
if (invoice.funded > 0n) {
Expand Down Expand Up @@ -71,6 +78,9 @@ export async function GET(
percentComplete,
rank: index + 1,
optedOut,
totalPaid: stats.received,
invoiceCount: stats.invoiceCount,
streak: stats.streak,
};
}
);
Expand Down
14 changes: 11 additions & 3 deletions src/hooks/useEmailValidation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,15 @@ const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const CACHE_TTL = 5 * 60 * 1000; // 5 minutes
const domainCache = new Map<string, CacheEntry>();

export function useEmailValidation(email: string, debounceMs = 500) {
export interface UseEmailValidationOptions {
debounceMs?: number;
checkMx?: boolean;
}

export function useEmailValidation(
email: string,
{ debounceMs = 500, checkMx = true }: UseEmailValidationOptions = {}
) {
const [isValidFormat, setIsValidFormat] = useState(false);
const [isCheckingMX, setIsCheckingMX] = useState(false);
const [mxValid, setMxValid] = useState<boolean | null>(null);
Expand All @@ -32,7 +40,7 @@ export function useEmailValidation(email: string, debounceMs = 500) {
const formatValid = EMAIL_PATTERN.test(email);
setIsValidFormat(formatValid);

if (!formatValid) {
if (!formatValid || !checkMx) {
setMxValid(null);
return;
}
Expand Down Expand Up @@ -81,7 +89,7 @@ export function useEmailValidation(email: string, debounceMs = 500) {
clearTimeout(debounceTimerRef.current);
}
};
}, [email, debounceMs, checkEmail]);
}, [email, debounceMs, checkEmail, checkMx]);

return {
isValidFormat,
Expand Down
43 changes: 42 additions & 1 deletion src/hooks/useFilterPresets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,20 @@ export interface UseFilterPresetsResult {
savePreset: (name: string, filters: Record<string, string>) => string | null;
renamePreset: (id: string, name: string) => string | null;
deletePreset: (id: string) => void;
exportPresets: () => string;
importPresets: (json: string) => void;
}

function isFilterPreset(value: unknown): value is FilterPreset {
if (typeof value !== "object" || value === null) return false;
const p = value as Record<string, unknown>;
return (
typeof p.id === "string" &&
typeof p.name === "string" &&
typeof p.createdAt === "string" &&
typeof p.filters === "object" &&
p.filters !== null
);
}

export function useFilterPresets(): UseFilterPresetsResult {
Expand Down Expand Up @@ -102,5 +116,32 @@ export function useFilterPresets(): UseFilterPresetsResult {
[presets]
);

return { presets, savePreset, renamePreset, deletePreset };
const exportPresets = useCallback((): string => {
return JSON.stringify(presets);
}, [presets]);

const importPresets = useCallback(
(json: string) => {
let parsed: unknown;
try {
parsed = JSON.parse(json);
} catch {
throw new Error("Invalid JSON");
}
if (!Array.isArray(parsed) || !parsed.every(isFilterPreset)) {
throw new Error("Invalid preset data");
}

const existingNames = new Set(presets.map((p) => normalizeName(p.name)));
const toAdd = parsed.filter((p) => !existingNames.has(normalizeName(p.name)));
if (toAdd.length === 0) return;

const next = [...presets, ...toAdd];
setPresets(next);
writePresets(next);
},
[presets]
);

return { presets, savePreset, renamePreset, deletePreset, exportPresets, importPresets };
}
17 changes: 13 additions & 4 deletions src/hooks/useGroupLeaderboard.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
'use client';

import { useEffect, useState, useRef } from 'react';
import type { GroupLeaderboardData } from '@/types/groupLeaderboard';
import { useEffect, useState, useRef, useMemo } from 'react';
import type { GroupLeaderboardData, LeaderboardSortBy } from '@/types/groupLeaderboard';

export function useGroupLeaderboard(groupId: string) {
export function useGroupLeaderboard(groupId: string, sortBy: LeaderboardSortBy = 'totalPaid') {
const [leaderboard, setLeaderboard] = useState<GroupLeaderboardData | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
Expand Down Expand Up @@ -49,6 +49,15 @@ export function useGroupLeaderboard(groupId: string) {

const mutate = fetchLeaderboard;

const sortedLeaderboard = useMemo(() => {
if (!leaderboard) return leaderboard;
const members = [...leaderboard.members].sort((a, b) => {
const diff = Number(b[sortBy]) - Number(a[sortBy]);
return diff !== 0 ? diff : a.rank - b.rank;
});
return { ...leaderboard, members };
}, [leaderboard, sortBy]);

useEffect(() => {
mountedRef.current = true;

Expand All @@ -71,7 +80,7 @@ export function useGroupLeaderboard(groupId: string) {
}, [groupId]);

return {
leaderboard,
leaderboard: sortedLeaderboard,
isLoading,
error,
updateOptOut,
Expand Down
35 changes: 27 additions & 8 deletions src/hooks/useSplitCalculator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,8 +54,12 @@ export interface RoundingResolution {

const STROOP_SCALE = 1e7;

function roundToStroops(value: number): number {
return Math.round(value * STROOP_SCALE) / STROOP_SCALE;
export type RoundingMode = 'floor' | 'ceil' | 'nearest';

function roundToStroops(value: number, mode: RoundingMode = 'nearest'): number {
const scaled = value * STROOP_SCALE;
const rounded = mode === 'floor' ? Math.floor(scaled) : mode === 'ceil' ? Math.ceil(scaled) : Math.round(scaled);
return rounded / STROOP_SCALE;
}

export function validateShares(recipients: RecipientLine[]): SplitValidation {
Expand Down Expand Up @@ -103,13 +107,27 @@ export function validateShares(recipients: RecipientLine[]): SplitValidation {
export function calculateSplit(
totalAmount: number,
recipients: RecipientLine[],
_assetCode: 'XLM' | 'USDC' = 'USDC'
_assetCode: 'XLM' | 'USDC' = 'USDC',
roundingMode: RoundingMode = 'nearest'
): SplitCalculatorResult {
const totalNum = totalAmount || 0;
const validation = validateShares(recipients);

const derivedLines: DerivedRecipientLine[] = recipients.map((r) => {
const grossAmount = roundToStroops((totalNum * (r.sharePercent || 0)) / 100);
const grossAmounts = recipients.map((r) =>
roundToStroops((totalNum * (r.sharePercent || 0)) / 100, roundingMode)
);

if (recipients.length > 0) {
const totalStroops = Math.round(totalNum * STROOP_SCALE);
const sumStroops = grossAmounts.reduce((s, v) => s + Math.round(v * STROOP_SCALE), 0);
const remainderStroops = totalStroops - sumStroops;
if (remainderStroops !== 0) {
grossAmounts[0] = (Math.round(grossAmounts[0] * STROOP_SCALE) + remainderStroops) / STROOP_SCALE;
}
}

const derivedLines: DerivedRecipientLine[] = recipients.map((r, i) => {
const grossAmount = grossAmounts[i];
const preFeeAmount = grossAmount;
const effectiveTaxAmount = roundToStroops(
(preFeeAmount * (r.taxRatePercent || 0)) / 100
Expand Down Expand Up @@ -153,11 +171,12 @@ export function calculateSplit(
export function useSplitCalculator(
totalAmount: number,
recipients: RecipientLine[],
assetCode: 'XLM' | 'USDC' = 'USDC'
assetCode: 'XLM' | 'USDC' = 'USDC',
roundingMode: RoundingMode = 'nearest'
): SplitCalculatorResult {
return useMemo(
() => calculateSplit(totalAmount, recipients, assetCode),
[totalAmount, recipients, assetCode]
() => calculateSplit(totalAmount, recipients, assetCode, roundingMode),
[totalAmount, recipients, assetCode, roundingMode]
);
}

Expand Down
5 changes: 5 additions & 0 deletions src/types/groupLeaderboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,13 @@ export interface GroupMember {
percentComplete: number;
rank: number;
optedOut: boolean;
totalPaid: bigint;
invoiceCount: number;
streak: number;
}

export type LeaderboardSortBy = 'totalPaid' | 'invoiceCount' | 'streak';

export interface GroupLeaderboardData {
groupId: string;
members: GroupMember[];
Expand Down