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+
918const CELL = 12 ;
1019const GAP = 3 ;
1120const ROWS = 7 ;
1221const 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
2227type 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