11import React , { useEffect , useMemo , useState } from "react" ;
22
33type StreakCalendarProps = {
4- /** localStorage key for ISO-date strings */
4+ /** localStorage key for ISO-date strings (YYYY-MM-DD) */
55 storageKey ?: string ;
66} ;
77
8+ /* ---------------- helpers ---------------- */
89const pad = ( n : number ) => String ( n ) . padStart ( 2 , "0" ) ;
910const toKey = ( d : Date ) =>
1011 `${ d . getFullYear ( ) } -${ pad ( d . getMonth ( ) + 1 ) } -${ pad ( d . getDate ( ) ) } ` ;
@@ -18,7 +19,7 @@ const lastNDaysKeys = (n: number) => {
1819 dt . setDate ( today . getDate ( ) - i ) ;
1920 keys . push ( toKey ( dt ) ) ;
2021 }
21- return keys ;
22+ return keys ; // oldest -> newest (today last)
2223} ;
2324
2425const readActiveSet = ( storageKey : string ) => {
@@ -43,83 +44,156 @@ const countCurrentStreak = (orderedKeys: string[], activeSet: Set<string>) => {
4344 return count ;
4445} ;
4546
47+ const WEEKDAYS = [ "S" , "M" , "T" , "W" , "T" , "F" , "S" ] ;
48+ const fmtReadable = ( k : string ) =>
49+ new Date ( k ) . toLocaleDateString ( undefined , {
50+ weekday : "short" ,
51+ month : "short" ,
52+ day : "numeric" ,
53+ } ) ;
54+
55+ // "Aug 03 → Sep 01"
56+ const fmtRange = ( startKey : string , endKey : string ) => {
57+ const f = ( k : string ) =>
58+ new Date ( k ) . toLocaleDateString ( undefined , { month : "short" , day : "2-digit" } ) ;
59+ return `${ f ( startKey ) } → ${ f ( endKey ) } ` ;
60+ } ;
61+
62+ // "August 2025" or "Aug–Sep 2025"
63+ const monthHeaderLabel = ( startKey : string , endKey : string ) => {
64+ const s = new Date ( startKey ) ;
65+ const e = new Date ( endKey ) ;
66+ const sameMonth = s . getMonth ( ) === e . getMonth ( ) && s . getFullYear ( ) === e . getFullYear ( ) ;
67+ if ( sameMonth ) {
68+ return s . toLocaleString ( undefined , { month : "long" , year : "numeric" } ) ;
69+ }
70+ const left = s . toLocaleString ( undefined , { month : "short" } ) ;
71+ const right = e . toLocaleString ( undefined , { month : "short" , year : "numeric" } ) ;
72+ return `${ left } –${ right } ` ;
73+ } ;
74+
75+ /* ---------------- component ---------------- */
4676const StreakCalendar : React . FC < StreakCalendarProps > = ( {
4777 storageKey = "cit_activeDates" ,
4878} ) => {
4979 const [ activeSet , setActiveSet ] = useState < Set < string > > ( new Set ( ) ) ;
5080
51- // read once on mount
5281 useEffect ( ( ) => {
5382 setActiveSet ( readActiveSet ( storageKey ) ) ;
5483 } , [ storageKey ] ) ;
5584
5685 const dateKeys = useMemo ( ( ) => lastNDaysKeys ( 30 ) , [ ] ) ;
5786 const todayKey = dateKeys [ dateKeys . length - 1 ] ;
87+ const startKey = dateKeys [ 0 ] ;
88+ const endKey = todayKey ;
89+
5890 const currentStreak = useMemo (
5991 ( ) => countCurrentStreak ( dateKeys , activeSet ) ,
6092 [ dateKeys , activeSet ]
6193 ) ;
6294
6395 return (
6496 < div >
65- { /* 7 columns grid; 30 cells -> last row will have 2 cells */ }
97+ { /* Month label + date range */ }
98+ < div className = "mb-1 flex items-center justify-between text-xs" >
99+ < div className = "text-gray-800 dark:text-gray-200 font-medium" >
100+ { monthHeaderLabel ( startKey , endKey ) }
101+ </ div >
102+ < div className = "text-gray-500 dark:text-gray-400" > { fmtRange ( startKey , endKey ) } </ div >
103+ </ div >
104+
105+ { /* Weekday header */ }
106+ < div className = "grid grid-cols-7 gap-2 text-[11px] sm:text-xs text-gray-500 dark:text-gray-400" >
107+ { WEEKDAYS . map ( ( d , i ) => (
108+ < div key = { `${ d } -${ i } ` } className = "text-center" >
109+ { d }
110+ </ div >
111+ ) ) }
112+ </ div >
113+
114+
115+ { /* Month markers row: show label where the date is the 1st */ }
116+ < div className = "grid grid-cols-7 gap-2 mt-1 mb-2 text-[10px] sm:text-[11px] text-gray-500 dark:text-gray-400" >
117+ { dateKeys . map ( ( k ) => {
118+ const d = new Date ( k ) ;
119+ return (
120+ < div key = { `m-${ k } ` } className = "text-center" >
121+ { d . getDate ( ) === 1
122+ ? d . toLocaleString ( undefined , { month : "short" } )
123+ : "" }
124+ </ div >
125+ ) ;
126+ } ) }
127+ </ div >
128+
129+ { /* Numbered month-style grid for last 30 days */ }
66130 < div
67- className = "grid grid-cols-7 gap-1 "
131+ className = "grid grid-cols-7 gap-2 "
68132 role = "grid"
69133 aria-label = "Last 30 days activity"
70134 >
71135 { dateKeys . map ( ( key ) => {
136+ const d = new Date ( key ) ;
137+ const dayNum = d . getDate ( ) ;
72138 const active = activeSet . has ( key ) ;
73139 const isToday = key === todayKey ;
140+ const isFirstOfMonth = dayNum === 1 ;
74141
75- const base =
76- "w-3.5 h-3.5 sm:w-4 sm:h-4 rounded-[4px] border outline-none " +
142+ const circleBase =
143+ "relative h-7 w-7 sm:h-8 sm:w-8 rounded-full flex items-center justify-center " +
144+ "text-[12px] sm:text-[13px] outline-none transition-transform " +
77145 "focus:ring-2 focus:ring-offset-1 focus:ring-indigo-500 dark:focus:ring-indigo-400 " +
78- "focus:ring-offset-white dark:focus:ring-offset-gray-800 transition-transform" ;
79- const state = active
80- ? "bg-emerald-500 dark:bg-emerald-400 border-emerald-600 dark:border-emerald-400"
81- : "bg-transparent border-gray-300 dark:border-gray-600" ;
82- const ring = isToday
146+ "focus:ring-offset-white dark:focus:ring-offset-gray-800" ;
147+
148+ const circleState = active
149+ ? "bg-emerald-500 dark:bg-emerald-400 text-white border border-emerald-600 dark:border-emerald-400"
150+ : "text-gray-600 dark:text-gray-300 border border-transparent hover:border-gray-300 dark:hover:border-gray-600" ;
151+
152+ const todayRing = isToday
83153 ? "ring-1 ring-indigo-500 dark:ring-indigo-400 ring-offset-1 ring-offset-white dark:ring-offset-gray-800"
84154 : "" ;
85- const readable = new Date ( key ) . toLocaleDateString ( undefined , {
86- weekday : "short" ,
87- month : "short" ,
88- day : "numeric" ,
89- } ) ;
155+
156+ const readable = fmtReadable ( key ) ;
90157
91158 return (
92159 < button
93160 key = { key }
94161 role = "gridcell"
95- className = { `${ base } ${ state } ${ ring } ` }
162+ className = { `${ circleBase } ${ circleState } ${ todayRing } ` }
96163 title = { `${ readable } • ${ active ? "Active" : "Inactive" } ` }
97164 aria-label = { `${ readable } — ${ active ? "Active" : "Inactive" } ` }
98165 aria-selected = { active }
99166 tabIndex = { 0 }
100- />
167+ >
168+ { /* Day number */ }
169+ < span className = "leading-none" > { dayNum } </ span >
170+
171+ { /* Tiny red dot to hint new month when not active */ }
172+ { isFirstOfMonth && ! active && (
173+ < span className = "absolute -bottom-0.5 h-1.5 w-1.5 rounded-full bg-rose-500" />
174+ ) }
175+ </ button >
101176 ) ;
102177 } ) }
103178 </ div >
104179
180+ { /* Current streak + legend */ }
105181 < div className = "mt-3 flex items-center justify-between text-sm" >
106182 < div className = "text-gray-700 dark:text-gray-300" >
107183 < span className = "font-medium" > Current Streak:</ span > { " " }
108184 { currentStreak } { currentStreak === 1 ? "day" : "days" }
109185 </ div >
110-
111- { /* tiny legend */ }
112186 < div className = "flex items-center gap-3 text-xs text-gray-500 dark:text-gray-400" >
113187 < span className = "inline-flex items-center" >
114- < span className = "w -3 h -3 rounded-[3px] bg-emerald-500 dark:bg-emerald-400 border border-emerald-600 dark:border-emerald-400 mr-1" />
188+ < span className = "h -3 w -3 rounded-full bg-emerald-500 dark:bg-emerald-400 border border-emerald-600 dark:border-emerald-400 mr-1" />
115189 Active
116190 </ span >
117191 < span className = "inline-flex items-center" >
118- < span className = "w -3 h -3 rounded-[3px] border border-gray-300 dark:border-gray-600 mr-1" />
192+ < span className = "h -3 w -3 rounded-full border border-gray-300 dark:border-gray-600 mr-1" />
119193 Inactive
120194 </ span >
121195 < span className = "inline-flex items-center" >
122- < span className = "w -3 h -3 rounded-[3px] border border-gray-300 dark:border-gray-600 ring-1 ring-indigo-500 dark:ring-indigo-400 ring-offset-1 ring-offset-white dark:ring-offset-gray-800 mr-1" />
196+ < span className = "h -3 w -3 rounded-full border border-gray-300 dark:border-gray-600 ring-1 ring-indigo-500 dark:ring-indigo-400 ring-offset-[2px] ring-offset-white dark:ring-offset-gray-800 mr-1" />
123197 Today
124198 </ span >
125199 </ div >
0 commit comments