Skip to content

Commit c697247

Browse files
sloweyyyslowey-katalonclaude
authored
feat(leetcode): year selector on heatmap, single-shot bulk fetch (#12)
Adopts LeetCode's profile UX: a year dropdown on the right side of the heatmap header lets users switch between "Current" (rolling 365 days) and any specific calendar year present in the stored data. Default selection is "Current" so first paint matches the previous behaviour. Card width stays at 53 weeks max — no more 2-year horizontal scroll overflow that pushed the "Total active days" counter out of frame. Build-time data fetch: - New pulse.leetcode.all() reads /v1/leetcode/all (single Pulse hit returning all 5 members' snapshots). - /leetcode and /m/:login both consume it; api client memoises by URL so the call only travels once per build process. - Drops the per-member fan-out that was causing intermittent stub pages on slow Cloud Run cold starts. Removed days={730} props (selector replaces them); component is now client-side ("use client") since the year toggle is interactive. Co-authored-by: Truong Le Vinh Phuc <phuc.truong@katalon.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 0042224 commit c697247

5 files changed

Lines changed: 185 additions & 94 deletions

File tree

app/globals.css

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1323,6 +1323,36 @@ footer {
13231323
margin-left: 2px;
13241324
}
13251325

1326+
.lc-heatmap-year {
1327+
appearance: none;
1328+
-webkit-appearance: none;
1329+
background: var(--surface-hi);
1330+
color: var(--text);
1331+
border: 1px solid var(--border);
1332+
border-radius: var(--r-button);
1333+
padding: 4px 26px 4px 10px;
1334+
font-family: var(--font-mono);
1335+
font-size: 0.78rem;
1336+
cursor: pointer;
1337+
background-image: linear-gradient(45deg, transparent 50%, var(--text-mute) 50%),
1338+
linear-gradient(135deg, var(--text-mute) 50%, transparent 50%);
1339+
background-position: calc(100% - 12px) center, calc(100% - 7px) center;
1340+
background-size: 5px 5px, 5px 5px;
1341+
background-repeat: no-repeat;
1342+
transition: border-color 120ms ease;
1343+
}
1344+
1345+
.lc-heatmap-year:hover,
1346+
.lc-heatmap-year:focus {
1347+
border-color: var(--border-hi);
1348+
outline: none;
1349+
}
1350+
1351+
.lc-heatmap-year option {
1352+
background: var(--surface-hi);
1353+
color: var(--text);
1354+
}
1355+
13261356
.lc-heatmap-scroll {
13271357
overflow-x: auto;
13281358
padding-bottom: 4px;

app/leetcode/page.tsx

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -12,17 +12,19 @@ export const metadata = {
1212
export const dynamic = "force-static";
1313

1414
export default async function LeetcodePage() {
15+
// One bulk call → 5x the cost saved vs hitting /v1/leetcode/:login per member.
16+
// The api client memoises on URL so /m/:login also reads from this same hit.
17+
const all = await pulse.leetcode.all().catch(() => [] as LeetcodeMemberStats[]);
18+
1519
const board = await pulse.leetcode.leaderboard("weighted").catch(() => ({
1620
sort: "weighted",
1721
count: 0,
1822
leaderboard: [] as LeetcodeLeaderboardEntry[],
1923
}));
2024

21-
// Hydrate per-member calendar in parallel for the heatmaps section.
22-
const stats = await Promise.all(
23-
board.leaderboard.map((row) =>
24-
pulse.leetcode.member(row.login).catch(() => null as LeetcodeMemberStats | null),
25-
),
25+
const byLogin = new Map(all.map((s) => [s.login, s]));
26+
const stats: Array<LeetcodeMemberStats | null> = board.leaderboard.map(
27+
(row) => byLogin.get(row.login) ?? null,
2628
);
2729

2830
return (
@@ -98,7 +100,7 @@ export default async function LeetcodePage() {
98100

99101
{stats.some(Boolean) && (
100102
<section className="section" style={{ paddingTop: 0 }}>
101-
<h2 className="lc-section-title">Submission heatmaps · past year</h2>
103+
<h2 className="lc-section-title">Submission heatmaps</h2>
102104
<div className="lc-heatmap-list">
103105
{stats.filter(Boolean).map((s) => {
104106
const stat = s!;
@@ -121,7 +123,7 @@ export default async function LeetcodePage() {
121123
</span>
122124
</div>
123125
</header>
124-
<LeetcodeHeatmap calendar={stat.submissionCalendar ?? {}} days={730} />
126+
<LeetcodeHeatmap calendar={stat.submissionCalendar ?? {}} />
125127
<LeetcodeBadges badges={stat.badges} variant="compact" limit={20} />
126128
</article>
127129
);

app/m/[username]/page.tsx

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,14 +34,17 @@ export default async function MemberPage({
3434
if (!(username in ROSTER)) notFound();
3535
const fallback = ROSTER[username as Username];
3636

37-
const [profile, eventsRes, leetcode] = await Promise.all([
37+
const [profile, eventsRes, allLeetcode] = await Promise.all([
3838
pulse.member(username).catch(() => null),
3939
pulse.events(20, username).catch(() => ({
4040
data: [] as ActivityEvent[],
4141
nextBefore: null as string | null,
4242
})),
43-
pulse.leetcode.member(username).catch(() => null as LeetcodeMemberStats | null),
43+
// Single bulk call shared with /leetcode via the api client cache —
44+
// dramatically more reliable than per-member fetches at build time.
45+
pulse.leetcode.all().catch(() => [] as LeetcodeMemberStats[]),
4446
]);
47+
const leetcode = allLeetcode.find((s) => s.login === username) ?? null;
4548
const events = eventsRes.data;
4649

4750
const name = profile?.name ?? fallback.name;
@@ -181,7 +184,7 @@ export default async function MemberPage({
181184
</div>
182185
</div>
183186

184-
<LeetcodeHeatmap calendar={leetcode.submissionCalendar ?? {}} days={730} />
187+
<LeetcodeHeatmap calendar={leetcode.submissionCalendar ?? {}} />
185188

186189
{leetcode.badges && leetcode.badges.length > 0 && (
187190
<div className="lc-badges-section">

components/LeetcodeHeatmap.tsx

Lines changed: 100 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -1,78 +1,99 @@
1+
"use client";
2+
13
/**
2-
* LeetCode-style submission heatmap — past 365 days ending today.
4+
* LeetCode-style submission heatmap with a year selector.
35
*
4-
* Server-rendered SVG. Calendar input is the raw map from LeetCode's
6+
* Calendar input is the raw map from LeetCode's
57
* userCalendar.submissionCalendar (UNIX-seconds string keys → submission
6-
* count). We render 7 rows × ~53 columns; opacity scales with intensity.
8+
* count). The component derives which calendar years exist in the data
9+
* and renders a "Current ▾" dropdown matching LeetCode's profile UX:
10+
* - "Current" → rolling 365 days ending today
11+
* - 2025 / 2024 / ... → that calendar year, Jan 1 → Dec 31
12+
*
13+
* Single-year render is always ≤53 weeks wide so the card never overflows.
714
*/
815

16+
import { useMemo, useState } from "react";
17+
918
const CELL = 12;
1019
const GAP = 3;
1120
const ROWS = 7;
1221
const MONTH_LABEL_H = 18;
1322

14-
function windowText(days: number, override?: string): string {
15-
if (override) return override;
16-
if (days < 365) return `${days} days`;
17-
const years = Math.round(days / 365);
18-
if (years === 1) return "year";
19-
return `${years} years`;
20-
}
23+
type View =
24+
| { kind: "current" }
25+
| { kind: "year"; year: number };
2126

2227
type Props = {
2328
calendar: Record<string, number>;
24-
endDate?: Date; // defaults to today
25-
/** How many days back from endDate to render. Defaults to 365 (1 year). */
26-
days?: number;
27-
/** Override the header copy when rendering a window other than 1 year. */
28-
windowLabel?: string;
29+
/** Initial selection. Defaults to "current" (rolling 365 days). */
30+
initialView?: View;
2931
};
3032

31-
export default function LeetcodeHeatmap({ calendar, endDate, days: daysWindow = 365, windowLabel }: Props) {
32-
const end = endDate ?? new Date();
33-
// anchor end at UTC midnight so cell keys line up with LeetCode's own keys
34-
const today = new Date(Date.UTC(end.getUTCFullYear(), end.getUTCMonth(), end.getUTCDate()));
35-
36-
// Build an ordered list of cells ending today, snapped to clean
37-
// Sunday-start weeks for the column layout.
38-
const days: Array<{ date: Date; count: number }> = [];
39-
const totalDays = daysWindow;
40-
// Walk back 365 days, then snap forward so the first day is a Sunday.
41-
const start = new Date(today);
42-
start.setUTCDate(start.getUTCDate() - (totalDays - 1));
43-
// Snap start back to the prior Sunday so columns are clean weeks.
44-
const startWeekday = start.getUTCDay(); // 0=Sun..6=Sat
45-
start.setUTCDate(start.getUTCDate() - startWeekday);
46-
47-
// Build until we're past today (inclusive)
48-
for (let d = new Date(start); d <= today; d.setUTCDate(d.getUTCDate() + 1)) {
49-
const epoch = Math.floor(d.getTime() / 1000).toString();
50-
const count = calendar[epoch] ?? 0;
51-
days.push({ date: new Date(d), count });
52-
}
33+
export default function LeetcodeHeatmap({ calendar, initialView }: Props) {
34+
// Years present in the data, newest first. Derived once per mount.
35+
const years = useMemo(() => {
36+
const set = new Set<number>();
37+
for (const k of Object.keys(calendar)) {
38+
const ts = Number(k);
39+
if (!Number.isFinite(ts)) continue;
40+
set.add(new Date(ts * 1000).getUTCFullYear());
41+
}
42+
return [...set].sort((a, b) => b - a);
43+
}, [calendar]);
5344

54-
// Pad to a clean grid (multiple of 7) — append future days as zero so the
55-
// last column always has 7 rows. They render but fall in the future-mask.
56-
while (days.length % 7 !== 0) {
57-
const last = days[days.length - 1]!.date;
58-
const next = new Date(last);
59-
next.setUTCDate(next.getUTCDate() + 1);
60-
days.push({ date: next, count: 0 });
61-
}
45+
const [view, setView] = useState<View>(initialView ?? { kind: "current" });
6246

63-
const weeks = days.length / 7;
47+
// Compute the day window for the selected view.
48+
const { rangeStart, rangeEnd, label } = useMemo(() => {
49+
if (view.kind === "current") {
50+
const t = new Date();
51+
const end = new Date(Date.UTC(t.getUTCFullYear(), t.getUTCMonth(), t.getUTCDate()));
52+
const start = new Date(end);
53+
start.setUTCDate(start.getUTCDate() - 364);
54+
return { rangeStart: start, rangeEnd: end, label: "past year" };
55+
}
56+
const start = new Date(Date.UTC(view.year, 0, 1));
57+
const end = new Date(Date.UTC(view.year, 11, 31));
58+
return { rangeStart: start, rangeEnd: end, label: view.year.toString() };
59+
}, [view]);
60+
61+
// Build the day grid, snapped to clean Sunday-start weeks.
62+
const cells = useMemo(() => {
63+
const arr: Array<{ date: Date; count: number; inRange: boolean }> = [];
64+
const start = new Date(rangeStart);
65+
const startWeekday = start.getUTCDay();
66+
start.setUTCDate(start.getUTCDate() - startWeekday);
67+
for (let d = new Date(start); d <= rangeEnd; d.setUTCDate(d.getUTCDate() + 1)) {
68+
const epoch = Math.floor(d.getTime() / 1000).toString();
69+
arr.push({
70+
date: new Date(d),
71+
count: calendar[epoch] ?? 0,
72+
inRange: d >= rangeStart && d <= rangeEnd,
73+
});
74+
}
75+
while (arr.length % 7 !== 0) {
76+
const last = arr[arr.length - 1]!.date;
77+
const next = new Date(last);
78+
next.setUTCDate(next.getUTCDate() + 1);
79+
arr.push({ date: next, count: 0, inRange: false });
80+
}
81+
return arr;
82+
}, [calendar, rangeStart, rangeEnd]);
83+
84+
const weeks = cells.length / 7;
6485
const width = weeks * (CELL + GAP);
6586
const height = ROWS * (CELL + GAP) + MONTH_LABEL_H;
6687

67-
// Stats: totals over the visible window, only counting cells <= today
68-
const visible = days.filter((d) => d.date <= today);
69-
const totalSubmissions = visible.reduce((sum, d) => sum + d.count, 0);
70-
const activeDays = visible.filter((d) => d.count > 0).length;
88+
// Stats — only count cells that are actually in the selected range.
89+
const visible = cells.filter((c) => c.inRange);
90+
const totalSubmissions = visible.reduce((s, c) => s + c.count, 0);
91+
const activeDays = visible.filter((c) => c.count > 0).length;
7192
const maxStreak = (() => {
7293
let best = 0;
7394
let cur = 0;
74-
for (const d of visible) {
75-
if (d.count > 0) {
95+
for (const c of visible) {
96+
if (c.count > 0) {
7697
cur += 1;
7798
if (cur > best) best = cur;
7899
} else {
@@ -82,7 +103,6 @@ export default function LeetcodeHeatmap({ calendar, endDate, days: daysWindow =
82103
return best;
83104
})();
84105

85-
// Intensity scale: 0, 1, 2-3, 4-7, 8+ → alpha 0/0.25/0.5/0.75/1
86106
const intensity = (n: number): number => {
87107
if (n <= 0) return 0;
88108
if (n === 1) return 0.3;
@@ -91,11 +111,11 @@ export default function LeetcodeHeatmap({ calendar, endDate, days: daysWindow =
91111
return 1;
92112
};
93113

94-
// Month labels — emit a label at the column of the first cell of each month
114+
// Month labels at first cell-column of each new month.
95115
const monthLabels: Array<{ x: number; label: string }> = [];
96116
let lastMonth = -1;
97117
for (let w = 0; w < weeks; w++) {
98-
const firstCell = days[w * 7];
118+
const firstCell = cells[w * 7];
99119
if (!firstCell) continue;
100120
const m = firstCell.date.getUTCMonth();
101121
if (m !== lastMonth) {
@@ -107,15 +127,31 @@ export default function LeetcodeHeatmap({ calendar, endDate, days: daysWindow =
107127
}
108128
}
109129

130+
const selectedValue = view.kind === "current" ? "current" : view.year.toString();
131+
110132
return (
111133
<div className="lc-heatmap">
112134
<div className="lc-heatmap-head">
113135
<div className="lc-heatmap-stats">
114-
<strong>{totalSubmissions}</strong> submissions in the past {windowText(daysWindow, windowLabel)}
136+
<strong>{totalSubmissions}</strong> submissions in the {label}
115137
</div>
116138
<div className="lc-heatmap-meta">
117139
<span>Total active days: <strong>{activeDays}</strong></span>
118140
<span>Max streak: <strong>{maxStreak}</strong></span>
141+
<select
142+
className="lc-heatmap-year"
143+
value={selectedValue}
144+
onChange={(e) => {
145+
const v = e.target.value;
146+
setView(v === "current" ? { kind: "current" } : { kind: "year", year: Number(v) });
147+
}}
148+
aria-label="Select year"
149+
>
150+
<option value="current">Current</option>
151+
{years.map((y) => (
152+
<option key={y} value={y}>{y}</option>
153+
))}
154+
</select>
119155
</div>
120156
</div>
121157

@@ -125,7 +161,7 @@ export default function LeetcodeHeatmap({ calendar, endDate, days: daysWindow =
125161
width={width}
126162
height={height}
127163
viewBox={`0 0 ${width} ${height}`}
128-
aria-label={`${totalSubmissions} submissions in the past ${windowText(daysWindow, windowLabel)}`}
164+
aria-label={`${totalSubmissions} submissions in the ${label}`}
129165
>
130166
{monthLabels.map((m) => (
131167
<text
@@ -140,21 +176,20 @@ export default function LeetcodeHeatmap({ calendar, endDate, days: daysWindow =
140176
</text>
141177
))}
142178

143-
{days.map((d, i) => {
179+
{cells.map((c, i) => {
144180
const col = Math.floor(i / 7);
145181
const row = i % 7;
146-
const isFuture = d.date > today;
147-
const a = intensity(d.count);
182+
const a = intensity(c.count);
148183
const fill =
149-
isFuture
184+
!c.inRange
150185
? "transparent"
151186
: a === 0
152187
? "var(--lc-empty, #1f2024)"
153188
: `rgba(0, 217, 146, ${a})`;
154-
const stroke = a === 0 && !isFuture ? "var(--lc-empty-stroke, #2a2c30)" : "transparent";
155-
const title = isFuture
156-
? d.date.toUTCString()
157-
: `${d.count} submission${d.count === 1 ? "" : "s"} on ${d.date.toLocaleDateString(
189+
const stroke = a === 0 && c.inRange ? "var(--lc-empty-stroke, #2a2c30)" : "transparent";
190+
const title = !c.inRange
191+
? c.date.toUTCString()
192+
: `${c.count} submission${c.count === 1 ? "" : "s"} on ${c.date.toLocaleDateString(
158193
"en",
159194
{ month: "short", day: "numeric", year: "numeric", timeZone: "UTC" },
160195
)}`;

0 commit comments

Comments
 (0)