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
205 changes: 205 additions & 0 deletions components/dashboard-recent-gains-client.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
"use client";

import { Clock, History, Trophy, X } from "lucide-react";
import Link from "next/link";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogClose,
DialogContent,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { cn } from "@/lib/utils";
import type { RouterOutputs } from "@/types/trpc";

const PREVIEW_COUNT = 4;

type RecentGains = RouterOutputs["xpTransaction"]["getRecentGains"];
type Gain = RecentGains[number];

function groupByMonth(gains: RecentGains) {
const map = new Map<string, { label: string; items: Gain[] }>();
for (const gain of gains) {
const key = `${gain.createdAt.getFullYear()}-${gain.createdAt.getMonth()}`;
const label = gain.createdAt.toLocaleDateString("en-US", {
month: "long",
year: "numeric",
});
const group = map.get(key);
if (group) {
group.items.push(gain);
} else {
map.set(key, { label, items: [gain] });
}
}
return Array.from(map.entries()).map(([key, value]) => ({ key, ...value }));
}

function ActivityItem({
activity,
compact = false,
}: {
activity: Gain;
compact?: boolean;
}) {
const hasChallenge =
activity.challengeSlug !== null && activity.challengeTitle !== null;

return (
<div
className={cn(
"bg-background neo-border-thick neo-shadow-sm relative before:absolute before:left-0 before:top-0 before:bottom-0 before:w-[4px] before:bg-primary",
compact ? "pl-4" : "pl-6",
)}
>
<div className={cn("flex items-start gap-3", compact ? "p-3" : "p-4")}>
<div className="p-1.5 neo-border-thick neo-shadow-sm rounded-lg bg-secondary shrink-0">
<Trophy
className={cn("text-primary", compact ? "w-3.5 h-3.5" : "w-4 h-4")}
/>
</div>
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-2">
{hasChallenge ? (
<Link
href={`/challenges/${activity.challengeSlug}`}
className="font-black hover:text-primary transition-colors text-sm block truncate"
>
{activity.challengeTitle}
</Link>
) : (
<span className="font-black text-sm block truncate">
{activity.description}
</span>
)}
{activity.xpAmount > 0 && (
<div className="px-1.5 py-0.5 bg-primary neo-border shrink-0">
<span className="text-xs font-black text-primary-foreground">
+{activity.xpAmount} XP
</span>
</div>
)}
</div>
{hasChallenge && (
<div className="flex items-center justify-between mt-0.5">
<p className="text-xs text-muted-foreground font-bold">
{activity.description}
</p>
<p className="text-xs text-muted-foreground font-bold shrink-0 ml-2">
{activity.createdAt.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
})}
</p>
</div>
)}
{!hasChallenge && (
<p className="text-xs text-muted-foreground font-bold mt-0.5">
{activity.createdAt.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
})}
</p>
)}
</div>
</div>
</div>
);
}

export function DashboardRecentGainsClient({
recentGains,
}: {
recentGains: RecentGains;
}) {
const preview = recentGains.slice(0, PREVIEW_COUNT);
const recentXp = recentGains.reduce((sum, g) => sum + g.xpAmount, 0);
const grouped = groupByMonth(recentGains);

return (
<div className="bg-secondary neo-border-thick neo-shadow p-8">
<div className="flex items-center gap-3 mb-6">
<div className="p-2 bg-primary neo-border-thick neo-shadow rounded-lg">
<Clock className="w-5 h-5 text-white" />
</div>
<h2 className="text-2xl font-black">Recent Activity</h2>
</div>

{recentGains.length === 0 ? (
<p className="text-muted-foreground font-bold text-sm py-4 text-center">
No activity yet. Complete a challenge to get started!
</p>
) : (
<>
<div className="space-y-4">
{preview.map((activity) => (
<ActivityItem key={activity.id} activity={activity} />
))}
</div>

<Dialog>
<DialogTrigger asChild>
<Button className="w-full mt-6 neo-border neo-shadow font-black">
View All Activity
<History className="ml-2 w-4 h-4" />
</Button>
</DialogTrigger>
<DialogContent
className="p-0 gap-0 overflow-hidden"
hideCloseButton
>
<DialogTitle className="sr-only">Activity Log</DialogTitle>

{/* Purple header with explicit close button */}
<div className="bg-primary px-6 py-5 flex items-start justify-between gap-4">
<div>
<div className="flex items-center gap-2 mb-3">
<Clock className="w-5 h-5 text-primary-foreground" />
<span className="text-xl font-black text-primary-foreground">
Activity Log
</span>
</div>
<div className="flex items-center gap-3">
<div className="px-3 py-1 bg-white/20 border border-white/30">
<span className="text-sm font-black text-primary-foreground">
+{recentXp} XP recently
</span>
</div>
<span className="text-primary-foreground/80 text-sm font-bold">
{recentGains.length} recent{" "}
{recentGains.length === 1 ? "activity" : "activities"}
</span>
</div>
</div>
<DialogClose className="text-primary-foreground/80 hover:text-primary-foreground transition-colors mt-1 shrink-0">
<X className="w-5 h-5" />
<span className="sr-only">Close</span>
</DialogClose>
</div>

{/* Grouped list */}
<div className="overflow-y-auto max-h-[55vh] p-5 space-y-5">
{grouped.map(({ label, key, items }) => (
<div key={key}>
<div className="flex items-center gap-3 mb-3">
<span className="text-xs font-black uppercase tracking-widest text-muted-foreground whitespace-nowrap">
{label}
</span>
<div className="flex-1 h-px bg-border" />
</div>
<div className="space-y-2">
{items.map((gain) => (
<ActivityItem key={gain.id} activity={gain} compact />
))}
</div>
</div>
))}
</div>
</DialogContent>
</Dialog>
</>
)}
</div>
);
}
60 changes: 2 additions & 58 deletions components/dashboard-recent-gains.tsx
Original file line number Diff line number Diff line change
@@ -1,66 +1,10 @@
import { ArrowRight, Clock, Trophy } from "lucide-react";
import Link from "next/link";
import { Button } from "@/components/ui/button";
import { getQueryClient, trpc } from "@/trpc/server";
import { DashboardRecentGainsClient } from "./dashboard-recent-gains-client";

export async function DashboardRecentGains() {
const queryClient = getQueryClient();

const recentGains = await queryClient.fetchQuery(
trpc.xpTransaction.getRecentGains.queryOptions(),
);

return (
<div className="bg-secondary neo-border-thick neo-shadow p-8">
<div className="flex items-center gap-3 mb-6">
<div className="p-2 bg-primary neo-border-thick neo-shadow rounded-lg">
<Clock className="w-5 h-5 text-white" />
</div>
<h2 className="text-2xl font-black">Recent Activity</h2>
</div>
<div className="space-y-4">
{recentGains.map((activity) => (
<div
key={activity.id}
className="p-4 bg-background neo-border-thick neo-shadow-sm relative pl-6 before:absolute before:left-0 before:top-0 before:bottom-0 before:w-2 before:bg-primary"
>
<div className="flex items-start gap-3">
<div className="p-2 neo-border-thick neo-shadow-sm rounded-lg bg-secondary">
<Trophy className="w-4 h-4 text-primary" />
</div>
<div className="flex-1">
<Link
href={`/challenges/${activity.challengeSlug}`}
className="font-black hover:text-primary transition-colors"
>
{activity.challengeTitle}
</Link>
<p className="text-sm text-muted-foreground font-bold mt-1">
{activity.description}
</p>
{activity.xpAmount > 0 && (
<div className="flex items-center gap-2 mt-2">
<div className="px-2 py-1 bg-primary neo-border rounded">
<span className="text-xs font-black text-primary-foreground">
+{activity.xpAmount} XP
</span>
</div>
</div>
)}
<p className="text-xs text-muted-foreground font-bold mt-2">
{activity.createdAt.toLocaleDateString()}
</p>
</div>
</div>
</div>
))}
</div>
<Button className="w-full mt-6 neo-border neo-shadow font-black" asChild>
<Link href="/challenges">
View All Challenges
<ArrowRight className="ml-2 w-4 h-4" />
</Link>
</Button>
</div>
);
return <DashboardRecentGainsClient recentGains={recentGains} />;
}
16 changes: 10 additions & 6 deletions components/ui/dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,10 @@ DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;

const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & {
hideCloseButton?: boolean;
}
>(({ className, children, hideCloseButton = false, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
Expand All @@ -44,10 +46,12 @@ const DialogContent = React.forwardRef<
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-lg opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
{!hideCloseButton && (
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-lg opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
));
Expand Down
2 changes: 1 addition & 1 deletion server/api/routers/xpTransaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ export const xpTransactionRouter = createTRPCRouter({
.leftJoin(challenge, eq(userXpTransaction.challengeId, challenge.id))
.where(eq(userXpTransaction.userId, userId))
.orderBy(desc(userXpTransaction.createdAt))
.limit(5);
.limit(20);

return recentGains;
}),
Expand Down