feat: more stat components

This commit is contained in:
syntaxbullet
2026-01-09 16:18:52 +01:00
parent 4a691ac71d
commit 682e9d208e
9 changed files with 603 additions and 96 deletions

View File

@@ -37,8 +37,38 @@ export const DashboardStatsSchema = z.object({
totalWealth: z.string(),
avgLevel: z.number(),
topStreak: z.number(),
totalItems: z.number().optional(),
}),
recentEvents: z.array(RecentEventSchema),
activeLootdrops: z.array(z.object({
rewardAmount: z.number(),
currency: z.string(),
createdAt: z.string(),
expiresAt: z.string().nullable(),
})).optional(),
lootdropState: z.object({
monitoredChannels: z.number(),
hottestChannel: z.object({
id: z.string(),
messages: z.number(),
progress: z.number(),
cooldown: z.boolean(),
}).nullable(),
config: z.object({
requiredMessages: z.number(),
dropChance: z.number(),
}),
}).optional(),
leaderboards: z.object({
topLevels: z.array(z.object({
username: z.string(),
level: z.number(),
})),
topWealth: z.array(z.object({
username: z.string(),
balance: z.string(),
})),
}).optional(),
uptime: z.number(),
lastCommandTimestamp: z.number().nullable(),
maintenanceMode: z.boolean(),

View File

@@ -163,6 +163,43 @@ class LootdropService {
return { success: false, error: "An error occurred while processing the reward." };
}
}
public getLootdropState() {
let hottestChannel: { id: string; messages: number; progress: number; cooldown: boolean; } | null = null;
let maxMessages = -1;
const window = config.lootdrop.activityWindowMs;
const now = Date.now();
const required = config.lootdrop.minMessages;
for (const [channelId, timestamps] of this.channelActivity.entries()) {
// Filter valid just to be sure we are reporting accurate numbers
const validCount = timestamps.filter(t => now - t < window).length;
// Check cooldown
const cooldownUntil = this.channelCooldowns.get(channelId);
const isOnCooldown = !!(cooldownUntil && now < cooldownUntil);
if (validCount > maxMessages) {
maxMessages = validCount;
hottestChannel = {
id: channelId,
messages: validCount,
progress: Math.min(100, (validCount / required) * 100),
cooldown: isOnCooldown
};
}
}
return {
monitoredChannels: this.channelActivity.size,
hottestChannel,
config: {
requiredMessages: required,
dropChance: config.lootdrop.spawnChance
}
};
}
public async clearCaches() {
this.channelActivity.clear();
this.channelCooldowns.clear();

View File

@@ -0,0 +1,128 @@
import { Card, CardContent, CardHeader, CardTitle } from "./ui/card";
import { Progress } from "./ui/progress";
import { Gift, Clock, Sparkles, Zap, Timer } from "lucide-react";
import { cn } from "../lib/utils";
import { Skeleton } from "./ui/skeleton";
export interface LootdropData {
rewardAmount: number;
currency: string;
createdAt: string;
expiresAt: string | null;
}
export interface LootdropState {
monitoredChannels: number;
hottestChannel: {
id: string;
messages: number;
progress: number;
cooldown: boolean;
} | null;
config: {
requiredMessages: number;
dropChance: number;
};
}
interface LootdropCardProps {
drop?: LootdropData | null;
state?: LootdropState;
isLoading?: boolean;
className?: string;
}
export function LootdropCard({ drop, state, isLoading, className }: LootdropCardProps) {
if (isLoading) {
return (
<Card className={cn("glass-card border-none bg-card/40", className)}>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium">Lootdrop Status</CardTitle>
<Gift className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="space-y-3">
<Skeleton className="h-8 w-[120px]" />
<Skeleton className="h-4 w-[80px]" />
</div>
</CardContent>
</Card>
);
}
const isActive = !!drop;
const progress = state?.hottestChannel?.progress || 0;
const isCooldown = state?.hottestChannel?.cooldown || false;
return (
<Card className={cn(
"glass-card border-none transition-all duration-500 overflow-hidden relative",
isActive ? "bg-primary/5 border-primary/20 hover-glow ring-1 ring-primary/20" : "bg-card/40",
className
)}>
{/* Ambient Background Effect */}
{isActive && (
<div className="absolute -right-4 -top-4 w-24 h-24 bg-primary/20 blur-3xl rounded-full pointer-events-none animate-pulse" />
)}
<CardHeader className="flex flex-row items-center justify-between pb-2 relative z-10">
<CardTitle className="text-sm font-medium flex items-center gap-2">
{isActive ? "Active Lootdrop" : "Lootdrop Potential"}
{isActive && (
<span className="relative flex h-2 w-2">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-primary opacity-75"></span>
<span className="relative inline-flex rounded-full h-2 w-2 bg-primary"></span>
</span>
)}
</CardTitle>
<Gift className={cn("h-4 w-4 transition-colors", isActive ? "text-primary " : "text-muted-foreground")} />
</CardHeader>
<CardContent className="relative z-10">
{isActive ? (
<div className="space-y-3 animate-in fade-in slide-up">
<div className="flex items-center justify-between">
<span className="text-2xl font-bold text-primary flex items-center gap-2">
<Sparkles className="w-5 h-5 text-yellow-500 fill-yellow-500 animate-pulse" />
{drop.rewardAmount.toLocaleString()} {drop.currency}
</span>
</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Clock className="w-3 h-3" />
<span>Dropped {new Date(drop.createdAt).toLocaleTimeString()}</span>
</div>
</div>
) : (
<div className="space-y-4">
{isCooldown ? (
<div className="flex flex-col items-center justify-center py-2 text-muted-foreground space-y-1">
<Timer className="w-6 h-6 text-yellow-500 opacity-80" />
<p className="text-sm font-medium text-yellow-500/80">Cooling Down...</p>
<p className="text-xs opacity-50">Channels are recovering.</p>
</div>
) : (
<div className="space-y-2">
<div className="flex items-center justify-between text-xs text-muted-foreground">
<div className="flex items-center gap-1.5">
<Zap className={cn("w-3 h-3", progress > 80 ? "text-yellow-500" : "text-muted-foreground")} />
<span>Next Drop Chance</span>
</div>
<span className="font-mono">{Math.round(progress)}%</span>
</div>
<Progress value={progress} className="h-1.5" indicatorClassName={cn(progress > 80 ? "bg-yellow-500" : "bg-primary")} />
{state?.hottestChannel ? (
<p className="text-[10px] text-muted-foreground text-right opacity-70">
{state.hottestChannel.messages} / {state.config.requiredMessages} msgs
</p>
) : (
<p className="text-[10px] text-muted-foreground text-center opacity-50 pt-1">
No recent activity
</p>
)}
</div>
)}
</div>
)}
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,98 @@
import React from "react";
import { Card, CardHeader, CardTitle, CardContent } from "./ui/card";
import { Badge } from "./ui/badge";
import { type RecentEvent } from "@shared/modules/dashboard/dashboard.types";
import { cn } from "../lib/utils";
import { Skeleton } from "./ui/skeleton";
function timeAgo(dateInput: Date | string) {
const date = new Date(dateInput);
const now = new Date();
const seconds = Math.floor((now.getTime() - date.getTime()) / 1000);
if (seconds < 60) return "just now";
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h ago`;
return date.toLocaleDateString();
}
interface RecentActivityProps {
events: RecentEvent[];
isLoading?: boolean;
className?: string;
}
export function RecentActivity({ events, isLoading, className }: RecentActivityProps) {
return (
<Card className={cn("glass-card border-none bg-card/40 h-full", className)}>
<CardHeader className="pb-2">
<CardTitle className="flex items-center justify-between text-lg font-medium">
<span className="flex items-center gap-2">
<span className="w-2 h-2 rounded-full bg-emerald-500 animate-pulse" />
Live Activity
</span>
{!isLoading && events.length > 0 && (
<Badge variant="glass" className="text-[10px] font-mono">
{events.length} EVENTS
</Badge>
)}
</CardTitle>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="space-y-4 pt-2">
{[1, 2, 3].map((i) => (
<div key={i} className="flex items-center gap-4">
<Skeleton className="h-10 w-10 rounded-lg" />
<div className="space-y-2 flex-1">
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-3 w-1/2" />
</div>
</div>
))}
</div>
) : events.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground space-y-2">
<div className="text-4xl">😴</div>
<p>No recent activity</p>
</div>
) : (
<div className="space-y-3 max-h-[400px] overflow-y-auto pr-2 -mr-2 custom-scrollbar">
{events.map((event, i) => (
<div
key={i}
className="group flex items-start gap-3 p-3 rounded-xl bg-background/30 hover:bg-background/50 border border-transparent hover:border-border/50 transition-all duration-300"
>
<div className="text-2xl p-2 rounded-lg bg-background/50 group-hover:scale-110 transition-transform">
{event.icon || "📝"}
</div>
<div className="flex-1 min-w-0 py-1">
<p className="text-sm font-medium leading-none truncate mb-1.5 text-foreground/90">
{event.message}
</p>
<div className="flex items-center gap-2">
<Badge
variant={
event.type === 'error' ? 'destructive' :
event.type === 'warn' ? 'destructive' :
event.type === 'success' ? 'aurora' : 'secondary'
}
className="text-[10px] h-4 px-1.5"
>
{event.type}
</Badge>
<span className="text-[10px] text-muted-foreground font-mono">
{timeAgo(event.timestamp)}
</span>
</div>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,53 @@
import React, { type ReactNode } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "./ui/card";
import { Skeleton } from "./ui/skeleton";
import { type LucideIcon } from "lucide-react";
import { cn } from "../lib/utils";
interface StatCardProps {
title: string;
value: ReactNode;
subtitle?: ReactNode;
icon: LucideIcon;
isLoading?: boolean;
className?: string;
valueClassName?: string;
iconClassName?: string;
}
export function StatCard({
title,
value,
subtitle,
icon: Icon,
isLoading = false,
className,
valueClassName,
iconClassName,
}: StatCardProps) {
return (
<Card className={cn("glass-card border-none bg-card/40 hover-glow", className)}>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">{title}</CardTitle>
<Icon className={cn("h-4 w-4", iconClassName || "text-muted-foreground")} />
</CardHeader>
<CardContent>
{isLoading ? (
<div className="space-y-2">
<Skeleton className="h-8 w-[60px]" />
<Skeleton className="h-3 w-[100px]" />
</div>
) : (
<>
<div className={cn("text-2xl font-bold", valueClassName)}>{value}</div>
{subtitle && (
<p className="text-xs text-muted-foreground mt-1">
{subtitle}
</p>
)}
</>
)}
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,24 @@
import * as React from "react"
import { cn } from "../../lib/utils"
const Progress = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & { value?: number | null, indicatorClassName?: string }
>(({ className, value, indicatorClassName, ...props }, ref) => (
<div
ref={ref}
className={cn(
"relative h-4 w-full overflow-hidden rounded-full bg-secondary/20",
className
)}
{...props}
>
<div
className={cn("h-full w-full flex-1 bg-primary transition-all", indicatorClassName)}
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</div>
))
Progress.displayName = "Progress"
export { Progress }

View File

@@ -2,9 +2,10 @@ import React from "react";
import { Link } from "react-router-dom";
import { useSocket } from "../hooks/use-socket";
import { Badge } from "../components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "../components/ui/card";
import { Skeleton } from "../components/ui/skeleton";
import { Server, Users, Terminal, Activity } from "lucide-react";
import { StatCard } from "../components/stat-card";
import { RecentActivity } from "../components/recent-activity";
import { LootdropCard } from "../components/lootdrop-card";
import { Server, Users, Terminal, Activity, Coins, TrendingUp, Flame, Package } from "lucide-react";
import { cn } from "../lib/utils";
export function Dashboard() {
@@ -60,102 +61,115 @@ export function Dashboard() {
{/* Stats Grid */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4 animate-in fade-in slide-up">
<Card className="glass-card border-none bg-card/40 hover-glow">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Total Servers</CardTitle>
<Server className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
{stats ? (
<>
<div className="text-2xl font-bold">{stats.guilds.count.toLocaleString()}</div>
<p className="text-xs text-muted-foreground mt-1">
{stats.guilds.changeFromLastMonth
? `${stats.guilds.changeFromLastMonth > 0 ? '+' : ''}${stats.guilds.changeFromLastMonth} from last month`
: "Active Guilds"
}
</p>
</>
) : (
<div className="space-y-2">
<Skeleton className="h-8 w-[60px]" />
<Skeleton className="h-3 w-[100px]" />
</div>
)}
</CardContent>
</Card>
<StatCard
title="Total Servers"
icon={Server}
isLoading={!stats}
value={stats?.guilds.count.toLocaleString()}
subtitle={stats?.guilds.changeFromLastMonth
? `${stats.guilds.changeFromLastMonth > 0 ? '+' : ''}${stats.guilds.changeFromLastMonth} from last month`
: "Active Guilds"
}
/>
<Card className="glass-card border-none bg-card/40 hover-glow delay-100">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Total Users</CardTitle>
<Users className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
{stats ? (
<>
<div className="text-2xl font-bold">{stats.users.total.toLocaleString()}</div>
<p className="text-xs text-muted-foreground mt-1">
{stats.users.active.toLocaleString()} active now
</p>
</>
) : (
<div className="space-y-2">
<Skeleton className="h-8 w-[60px]" />
<Skeleton className="h-3 w-[100px]" />
</div>
)}
</CardContent>
</Card>
<StatCard
title="Total Users"
icon={Users}
isLoading={!stats}
value={stats?.users.total.toLocaleString()}
subtitle={stats ? `${stats.users.active.toLocaleString()} active now` : undefined}
className="delay-100"
/>
<Card className="glass-card border-none bg-card/40 hover-glow delay-200">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Commands</CardTitle>
<Terminal className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
{stats ? (
<>
<div className="text-2xl font-bold">{stats.commands.total.toLocaleString()}</div>
<p className="text-xs text-muted-foreground mt-1">
{stats.commands.active} active · {stats.commands.disabled} disabled
</p>
</>
) : (
<div className="space-y-2">
<Skeleton className="h-8 w-[60px]" />
<Skeleton className="h-3 w-[100px]" />
</div>
)}
</CardContent>
</Card>
<StatCard
title="Commands"
icon={Terminal}
isLoading={!stats}
value={stats?.commands.total.toLocaleString()}
subtitle={stats ? `${stats.commands.active} active · ${stats.commands.disabled} disabled` : undefined}
className="delay-200"
/>
<Card className="glass-card border-none bg-card/40 hover-glow delay-300">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">System Ping</CardTitle>
<Activity className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
{stats ? (
<>
<div className={cn(
"text-2xl font-bold transition-colors duration-300",
stats.ping.avg < 100 ? "text-emerald-500" :
stats.ping.avg < 200 ? "text-yellow-500" : "text-red-500"
)}>
{Math.round(stats.ping.avg)}ms
</div>
<p className="text-xs text-muted-foreground mt-1">
Average latency
</p>
</>
) : (
<div className="space-y-2">
<Skeleton className="h-8 w-[60px]" />
<Skeleton className="h-3 w-[100px]" />
</div>
)}
</CardContent>
</Card>
<StatCard
title="System Ping"
icon={Activity}
isLoading={!stats}
value={stats ? `${Math.round(stats.ping.avg)}ms` : undefined}
subtitle="Average latency"
className="delay-300"
valueClassName={stats ? cn(
"transition-colors duration-300",
stats.ping.avg < 100 ? "text-emerald-500" :
stats.ping.avg < 200 ? "text-yellow-500" : "text-red-500"
) : undefined}
/>
</div>
<div className="grid gap-8 lg:grid-cols-3 animate-in fade-in slide-up delay-300">
{/* Economy Stats */}
<div className="lg:col-span-2 space-y-4">
<h2 className="text-xl font-semibold tracking-tight">Economy Overview</h2>
<div className="grid gap-4 md:grid-cols-2">
<StatCard
title="Total Wealth"
icon={Coins}
isLoading={!stats}
value={stats ? `${Number(stats.economy.totalWealth).toLocaleString()} AU` : undefined}
subtitle="Astral Units in circulation"
valueClassName="text-primary"
iconClassName="text-primary"
/>
<StatCard
title="Items Circulating"
icon={Package}
isLoading={!stats}
value={stats?.economy.totalItems?.toLocaleString()}
subtitle="Total items owned by users"
className="delay-75"
valueClassName="text-blue-500"
iconClassName="text-blue-500"
/>
<StatCard
title="Average Level"
icon={TrendingUp}
isLoading={!stats}
value={stats ? `Lvl ${stats.economy.avgLevel}` : undefined}
subtitle="Global player average"
className="delay-100"
valueClassName="text-secondary"
iconClassName="text-secondary"
/>
<StatCard
title="Top /daily Streak"
icon={Flame}
isLoading={!stats}
value={stats?.economy.topStreak}
subtitle="Days daily streak"
className="delay-200"
valueClassName="text-destructive"
iconClassName="text-destructive"
/>
</div>
</div>
{/* Recent Activity */}
{/* Recent Activity & Lootdrops */}
<div className="space-y-4">
<LootdropCard
drop={stats?.activeLootdrops?.[0]}
state={stats?.lootdropState}
isLoading={!stats}
/>
<h2 className="text-xl font-semibold tracking-tight">Live Feed</h2>
<RecentActivity
events={stats?.recentEvents || []}
isLoading={!stats}
className="h-[calc(100%-2rem)]"
/>
</div>
</div>
</main>
</div>

View File

@@ -8,6 +8,25 @@ import { FeatureCard } from "../components/feature-card";
import { InfoCard } from "../components/info-card";
import { SectionHeader } from "../components/section-header";
import { TestimonialCard } from "../components/testimonial-card";
import { StatCard } from "../components/stat-card";
import { LootdropCard } from "../components/lootdrop-card";
import { Activity, Coins, Flame } from "lucide-react";
import { RecentActivity } from "../components/recent-activity";
import { type RecentEvent } from "@shared/modules/dashboard/dashboard.types";
const mockEvents: RecentEvent[] = [
{ type: 'success', message: 'User leveled up to 5', timestamp: new Date(Date.now() - 1000 * 60 * 5), icon: '⬆️' },
{ type: 'info', message: 'New user joined', timestamp: new Date(Date.now() - 1000 * 60 * 15), icon: '👋' },
{ type: 'warn', message: 'Failed login attempt', timestamp: new Date(Date.now() - 1000 * 60 * 60), icon: '⚠️' }
];
const mockManyEvents: RecentEvent[] = Array.from({ length: 15 }).map((_, i) => ({
type: i % 3 === 0 ? 'success' : i % 3 === 1 ? 'info' : 'error', // Use string literals matching the type definition
message: `Event #${i + 1} generated for testing scroll behavior`,
timestamp: new Date(Date.now() - 1000 * 60 * i * 10),
icon: i % 3 === 0 ? '✨' : i % 3 === 1 ? '' : '🚨',
}));
export function DesignSystem() {
return (
@@ -249,6 +268,79 @@ export function DesignSystem() {
/>
</div>
{/* Stat Cards Demo */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<StatCard
title="Standard Stat"
value="1,234"
subtitle="Active users"
icon={Activity}
isLoading={false}
/>
<StatCard
title="Colored Stat"
value="9,999 AU"
subtitle="Total Wealth"
icon={Coins}
isLoading={false}
valueClassName="text-primary"
iconClassName="text-primary"
/>
<StatCard
title="Loading State"
value={null}
icon={Flame}
isLoading={true}
/>
</div>
{/* Game Event Cards Demo */}
<div className="space-y-4">
<h3 className="text-xl font-semibold text-muted-foreground">Game Event Cards</h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<LootdropCard
isLoading={true}
/>
<LootdropCard
drop={null}
state={{
monitoredChannels: 3,
hottestChannel: {
id: "123",
messages: 42,
progress: 42,
cooldown: false
},
config: { requiredMessages: 100, dropChance: 0.1 }
}}
isLoading={false}
/>
<LootdropCard
drop={null}
state={{
monitoredChannels: 3,
hottestChannel: {
id: "123",
messages: 100,
progress: 100,
cooldown: true
},
config: { requiredMessages: 100, dropChance: 0.1 }
}}
isLoading={false}
/>
<LootdropCard
drop={{
rewardAmount: 500,
currency: "AU",
createdAt: new Date().toISOString(),
expiresAt: new Date(Date.now() + 60000).toISOString()
}}
isLoading={false}
/>
</div>
</div>
{/* Testimonial Cards Demo */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<TestimonialCard
@@ -258,6 +350,33 @@ export function DesignSystem() {
avatarGradient="bg-gradient-to-br from-pink-500 to-rose-500"
/>
</div>
{/* Recent Activity Demo */}
<div className="space-y-4">
<h3 className="text-xl font-semibold text-muted-foreground">Recent Activity Feed</h3>
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-6 h-[500px]">
<RecentActivity
events={[]}
isLoading={true}
className="h-full"
/>
<RecentActivity
events={[]}
isLoading={false}
className="h-full"
/>
<RecentActivity
events={mockEvents}
isLoading={false}
className="h-full"
/>
<RecentActivity
events={mockManyEvents}
isLoading={false}
className="h-full"
/>
</div>
</div>
</div>
</section>

View File

@@ -363,6 +363,7 @@ export async function createWebServer(config: WebServerConfig = {}): Promise<Web
async function getFullDashboardStats() {
// Import services (dynamic to avoid circular deps)
const { dashboardService } = await import("@shared/modules/dashboard/dashboard.service");
const { lootdropService } = await import("@shared/modules/economy/lootdrop.service");
const { getClientStats } = await import("../../bot/lib/clientStats");
// Fetch all data in parallel with error isolation
@@ -375,6 +376,7 @@ export async function createWebServer(config: WebServerConfig = {}): Promise<Web
dashboardService.getTotalItems(),
dashboardService.getActiveLootdrops(),
dashboardService.getLeaderboards(),
Promise.resolve(lootdropService.getLootdropState()),
]);
// Helper to unwrap result or return default
@@ -402,6 +404,7 @@ export async function createWebServer(config: WebServerConfig = {}): Promise<Web
const totalItems = unwrap(results[5], 0, 'totalItems');
const activeLootdrops = unwrap(results[6], [], 'activeLootdrops');
const leaderboards = unwrap(results[7], { topLevels: [], topWealth: [] }, 'leaderboards');
const lootdropState = unwrap(results[8], undefined, 'lootdropState');
return {
bot: clientStats.bot,
@@ -430,6 +433,7 @@ export async function createWebServer(config: WebServerConfig = {}): Promise<Web
expiresAt: drop.expiresAt ? drop.expiresAt.toISOString() : null,
// Explicitly excluding channelId/messageId to prevent sniping
})),
lootdropState,
leaderboards,
uptime: clientStats.uptime,
lastCommandTimestamp: clientStats.lastCommandTimestamp,