feat: add top monthly contributor highlight in user menu

- Add getTopMonthlyContributor() method to SupportersService to find the supporter with the highest donations in the current calendar month
- Display top contributor below the progress bar in user menu with trophy icon, display name, and amount
- Use useLiveQuery for reactive updates when new zaps arrive
- Format amounts consistently (k/M notation for readability)
- Only show when there is at least one contributor for the month
This commit is contained in:
Claude
2026-01-20 09:21:03 +00:00
parent c2f6f1bcd2
commit 93440b103b
2 changed files with 85 additions and 1 deletions

View File

@@ -8,6 +8,7 @@ import {
Eye,
EyeOff,
Zap,
Trophy,
} from "lucide-react";
import accounts from "@/services/accounts";
import { useProfile } from "@/hooks/useProfile";
@@ -51,7 +52,7 @@ import {
GRIMOIRE_DONATE_PUBKEY,
GRIMOIRE_LIGHTNING_ADDRESS,
} from "@/lib/grimoire-members";
import { MONTHLY_GOAL_SATS } from "@/services/supporters";
import supportersService, { MONTHLY_GOAL_SATS } from "@/services/supporters";
function UserAvatar({ pubkey }: { pubkey: string }) {
const profile = useProfile(pubkey);
@@ -82,6 +83,36 @@ function UserLabel({ pubkey }: { pubkey: string }) {
);
}
function TopContributor({
pubkey,
amount,
}: {
pubkey: string;
amount: number;
}) {
const profile = useProfile(pubkey);
const displayName = getDisplayName(pubkey, profile);
function formatSats(sats: number): string {
if (sats >= 1_000_000) {
return `${(sats / 1_000_000).toFixed(1)}M`;
} else if (sats >= 1_000) {
return `${Math.floor(sats / 1_000)}k`;
}
return sats.toString();
}
return (
<div className="flex items-center gap-2 mt-2 pt-2 border-t border-border/50">
<Trophy className="size-3.5 text-yellow-500" />
<span className="text-xs text-muted-foreground flex-1 truncate">
{displayName}
</span>
<span className="text-xs font-medium">{formatSats(amount)} sats</span>
</div>
);
}
export default function UserMenu() {
const account = use$(accounts.active$);
const { state, addWindow, disconnectNWC, toggleWalletBalancesBlur } =
@@ -109,6 +140,12 @@ export default function UserMenu() {
return total;
}, []) ?? 0;
// Get top monthly contributor reactively
const topContributor = useLiveQuery(
async () => supportersService.getTopMonthlyContributor(),
[],
);
// Calculate monthly donation progress
const goalProgress = (monthlyDonations / MONTHLY_GOAL_SATS) * 100;
@@ -442,6 +479,12 @@ export default function UserMenu() {
</span>
</div>
<Progress value={goalProgress} className="h-1.5" />
{topContributor && (
<TopContributor
pubkey={topContributor.pubkey}
amount={topContributor.totalSats}
/>
)}
</div>
</DropdownMenuGroup>

View File

@@ -336,6 +336,47 @@ class SupportersService {
return total;
}
/**
* Get top contributor for the current calendar month
* Returns the supporter with the highest total zaps this month
*/
async getTopMonthlyContributor(): Promise<SupporterInfo | undefined> {
const now = new Date();
const firstOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
const firstOfMonthTimestamp = Math.floor(firstOfMonth.getTime() / 1000);
const supporterMap = new Map<string, SupporterInfo>();
// Aggregate zaps by sender for current month
await db.grimoireZaps
.where("timestamp")
.aboveOrEqual(firstOfMonthTimestamp)
.each((zap) => {
const existing = supporterMap.get(zap.senderPubkey);
if (existing) {
existing.totalSats += zap.amountSats;
existing.zapCount += 1;
existing.lastZapTimestamp = Math.max(
existing.lastZapTimestamp,
zap.timestamp,
);
} else {
supporterMap.set(zap.senderPubkey, {
pubkey: zap.senderPubkey,
totalSats: zap.amountSats,
zapCount: 1,
lastZapTimestamp: zap.timestamp,
});
}
});
// Find top contributor
const supporters = Array.from(supporterMap.values());
if (supporters.length === 0) return undefined;
return supporters.sort((a, b) => b.totalSats - a.totalSats)[0];
}
/**
* Get supporter count using Dexie uniqueKeys
*/