From 93440b103bd8c06aaa4fd752f55b24a2fcaf7a55 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Jan 2026 09:21:03 +0000 Subject: [PATCH] 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 --- src/components/nostr/user-menu.tsx | 45 +++++++++++++++++++++++++++++- src/services/supporters.ts | 41 +++++++++++++++++++++++++++ 2 files changed, 85 insertions(+), 1 deletion(-) diff --git a/src/components/nostr/user-menu.tsx b/src/components/nostr/user-menu.tsx index 7bd0fec..aadba05 100644 --- a/src/components/nostr/user-menu.tsx +++ b/src/components/nostr/user-menu.tsx @@ -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 ( +
+ + + {displayName} + + {formatSats(amount)} sats +
+ ); +} + 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() { + {topContributor && ( + + )} diff --git a/src/services/supporters.ts b/src/services/supporters.ts index bee563c..88c3966 100644 --- a/src/services/supporters.ts +++ b/src/services/supporters.ts @@ -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 { + const now = new Date(); + const firstOfMonth = new Date(now.getFullYear(), now.getMonth(), 1); + const firstOfMonthTimestamp = Math.floor(firstOfMonth.getTime() / 1000); + + const supporterMap = new Map(); + + // 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 */