Skip to content

Commit 77f2f96

Browse files
committed
feat(wins): public /wins feed + per-handle profile pages
- New Convex query 'listPublicWins' in milestones.ts: paginated, indexed by createdAt desc, joins with user (handle/name) and project (slug) - Returns PII-free shape: productName, week, score, streak, oneThing - Safe defaults if user/project deleted - New Convex query 'getPublicWinCount' for hero stats - New /wins landing page: hero with 4 headline metrics (wins, founders, avg score, longest streak), grid of WinCard components, empty state with onboarding CTA, and 'how it works' footer - New /wins/[handle] profile: per-founder timeline, total/longest/avg, oldest-to-newest view, 404 if no wins - Both pages use ConvexHttpClient for SSR (faster than useQuery on first paint) - Both pages have generateMetadata for SEO - Updated landing page: third CTA 'See founders shipping →' links to /wins - Web type-check + build clean; 37/37 web tests still passing
1 parent 782edc4 commit 77f2f96

4 files changed

Lines changed: 507 additions & 0 deletions

File tree

‎packages/web/convex/milestones.ts‎

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,3 +126,54 @@ export const postPublicWin = mutation({
126126
return { winId };
127127
},
128128
});
129+
130+
// ─── Public wins feed (v0.2.0) ───────────────────────────────────
131+
// Unauthenticated, read-only feed of recent public wins. Used by the
132+
// /wins landing page and the homepage hero.
133+
134+
export const listPublicWins = query({
135+
args: {
136+
limit: v.optional(v.number()),
137+
},
138+
handler: async (ctx, args) => {
139+
const limit = Math.min(Math.max(args.limit ?? 50, 1), 200);
140+
const wins = await ctx.db
141+
.query("publicWins")
142+
.withIndex("by_created_at")
143+
.order("desc")
144+
.take(limit);
145+
146+
// Join with user/project for display fields. We deliberately do
147+
// not expose emails or any PII — only productName and a public slug.
148+
return Promise.all(
149+
wins.map(async (w) => {
150+
const user: any = await ctx.db.get(w.userId);
151+
const project = await ctx.db.get(w.projectId);
152+
return {
153+
_id: w._id,
154+
productName: w.productName,
155+
weekNum: w.weekNum,
156+
shippingScore: w.shippingScore,
157+
streak: w.streak,
158+
tasksCompleted: w.tasksCompleted,
159+
tasksTotal: w.tasksTotal,
160+
feedbackCount: w.feedbackCount,
161+
loopkitScore: w.loopkitScore ?? null,
162+
mrr: w.mrr ?? null,
163+
oneThing: w.oneThing,
164+
createdAt: w.createdAt,
165+
handle: (user?.handle as string | undefined) ?? (user?.name as string | undefined) ?? "anon",
166+
projectSlug: (project as { slug?: string } | null)?.slug ?? null,
167+
};
168+
}),
169+
);
170+
},
171+
});
172+
173+
export const getPublicWinCount = query({
174+
args: {},
175+
handler: async (ctx) => {
176+
const wins = await ctx.db.query("publicWins").collect();
177+
return wins.length;
178+
},
179+
});

‎packages/web/src/app/page.tsx‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,13 @@ export default function LandingPage() {
180180
>
181181
See how it works →
182182
</Link>
183+
184+
<Link
185+
href="/wins"
186+
className="px-6 py-3 rounded-xl bg-zinc-900 border border-zinc-800 text-zinc-300 hover:text-white transition-colors text-sm"
187+
>
188+
See founders shipping →
189+
</Link>
183190
</div>
184191

185192
{/* Trust line */}
Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
/**
2+
* Per-handle public wins profile: /wins/[handle]
3+
* Aggregates all public wins by a single user, oldest to newest.
4+
* Shows: 52-week score timeline, total wins, longest streak.
5+
*/
6+
7+
import Link from "next/link";
8+
import { notFound } from "next/navigation";
9+
import { ConvexHttpClient } from "convex/browser";
10+
import { api } from "../../../../convex/_generated/api";
11+
import { ArrowLeft, Flame, TrendingUp, Calendar } from "lucide-react";
12+
13+
const CONVEX_URL =
14+
process.env.NEXT_PUBLIC_CONVEX_URL || process.env.CONVEX_URL || "";
15+
16+
interface PublicWin {
17+
_id: string;
18+
productName: string;
19+
weekNum: number;
20+
shippingScore: number;
21+
streak: number;
22+
tasksCompleted: number;
23+
tasksTotal: number;
24+
feedbackCount: number;
25+
loopkitScore: number | null;
26+
mrr: number | null;
27+
oneThing: string;
28+
createdAt: number;
29+
handle: string;
30+
projectSlug: string | null;
31+
}
32+
33+
async function fetchWinsForHandle(handle: string): Promise<PublicWin[]> {
34+
if (!CONVEX_URL) return [];
35+
try {
36+
const client = new ConvexHttpClient(CONVEX_URL);
37+
const wins = await client.query(api.milestones.listPublicWins, { limit: 200 });
38+
return (wins as PublicWin[]).filter(
39+
(w) => w.handle.toLowerCase() === handle.toLowerCase(),
40+
);
41+
} catch (err) {
42+
console.error("Failed to load handle wins:", err);
43+
return [];
44+
}
45+
}
46+
47+
export async function generateMetadata({ params }: { params: { handle: string } }) {
48+
return {
49+
title: `@${params.handle} — LoopKit Wins`,
50+
description: `Public shipping wins by @${params.handle} on LoopKit.`,
51+
};
52+
}
53+
54+
function formatDate(ts: number): string {
55+
return new Date(ts).toISOString().split("T")[0];
56+
}
57+
58+
export default async function HandleWinsPage({
59+
params,
60+
}: {
61+
params: { handle: string };
62+
}) {
63+
const wins = await fetchWinsForHandle(params.handle);
64+
65+
if (wins.length === 0) {
66+
notFound();
67+
}
68+
69+
// Sort oldest to newest
70+
const sorted = [...wins].sort((a, b) => a.createdAt - b.createdAt);
71+
const totalWins = sorted.length;
72+
const longestStreak = sorted.reduce((max, w) => Math.max(max, w.streak), 0);
73+
const avgScore = Math.round(
74+
sorted.reduce((s, w) => s + w.shippingScore, 0) / sorted.length,
75+
);
76+
const productName = sorted[sorted.length - 1].productName;
77+
const firstWin = formatDate(sorted[0].createdAt);
78+
79+
return (
80+
<main id="main-content" className="max-w-3xl mx-auto px-6 py-16">
81+
<Link
82+
href="/wins"
83+
className="inline-flex items-center gap-1.5 text-sm text-zinc-500 hover:text-zinc-300 transition-colors mb-8"
84+
>
85+
<ArrowLeft className="h-4 w-4" aria-hidden="true" />
86+
All wins
87+
</Link>
88+
89+
<header className="mb-12">
90+
<div className="flex items-baseline gap-2 mb-2">
91+
<span className="text-zinc-500 text-sm font-mono">@</span>
92+
<h1 className="text-3xl font-bold text-white">{params.handle}</h1>
93+
</div>
94+
<p className="text-zinc-400 text-sm">
95+
Shipping <span className="text-white font-medium">{productName}</span>{" "}
96+
since {firstWin}.
97+
</p>
98+
99+
<dl className="mt-6 grid grid-cols-3 gap-3">
100+
<div className="p-4 rounded-xl border border-zinc-800 bg-zinc-900/30">
101+
<dt className="text-xs text-zinc-500 uppercase tracking-wider">Wins</dt>
102+
<dd className="mt-1 text-2xl font-bold text-white">{totalWins}</dd>
103+
</div>
104+
<div className="p-4 rounded-xl border border-orange-500/20 bg-zinc-900/30">
105+
<dt className="text-xs text-orange-400 uppercase tracking-wider flex items-center gap-1">
106+
<Flame className="h-3 w-3" /> Longest
107+
</dt>
108+
<dd className="mt-1 text-2xl font-bold text-white">{longestStreak}</dd>
109+
</div>
110+
<div className="p-4 rounded-xl border border-zinc-800 bg-zinc-900/30">
111+
<dt className="text-xs text-zinc-500 uppercase tracking-wider">Avg</dt>
112+
<dd className="mt-1 text-2xl font-bold text-white">{avgScore}%</dd>
113+
</div>
114+
</dl>
115+
</header>
116+
117+
<section>
118+
<h2 className="text-sm font-medium text-zinc-400 mb-4 flex items-center gap-2">
119+
<Calendar className="h-4 w-4" aria-hidden="true" />
120+
Timeline
121+
</h2>
122+
123+
<ol className="relative space-y-6 border-l border-zinc-800 pl-6 ml-2">
124+
{sorted.map((win) => (
125+
<li key={win._id} className="relative">
126+
<span className="absolute -left-[31px] top-1 w-3 h-3 rounded-full bg-violet-500 ring-4 ring-zinc-950" />
127+
<div className="p-4 rounded-xl border border-zinc-800 bg-zinc-900/30">
128+
<div className="flex items-center justify-between mb-2">
129+
<div className="text-xs text-zinc-500 font-mono">
130+
Week {win.weekNum} · {formatDate(win.createdAt)}
131+
</div>
132+
<div className="flex items-center gap-2 text-xs">
133+
{win.streak >= 4 && (
134+
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full border border-orange-500/30 bg-orange-500/10 text-orange-400">
135+
<Flame className="h-3 w-3" />
136+
{win.streak}
137+
</span>
138+
)}
139+
<span className="inline-flex items-center gap-1 text-emerald-400">
140+
<TrendingUp className="h-3 w-3" />
141+
{win.shippingScore}%
142+
</span>
143+
</div>
144+
</div>
145+
<p className="text-sm text-zinc-300 italic">
146+
&ldquo;{win.oneThing}&rdquo;
147+
</p>
148+
</div>
149+
</li>
150+
))}
151+
</ol>
152+
</section>
153+
</main>
154+
);
155+
}

0 commit comments

Comments
 (0)