1- import { useMemo } from "react" ;
1+ import { useMemo , useRef , useEffect , useState } from "react" ;
2+
3+ type ViewMode = "weekday" | "hour" ;
24
35interface ActivityHeatmapProps {
4- /** Map of date string (YYYY-MM-DD) to session count */
5- data : Map < string , number > ;
6+ /** Map of date (YYYY-MM-DD) to count */
7+ daily : Record < string , number > ;
8+ /** Map of "date:hour" (YYYY-MM-DD:HH) to count */
9+ detailed : Record < string , number > ;
610}
711
8- export function ActivityHeatmap ( { data } : ActivityHeatmapProps ) {
9- const { weeks, maxCount, totalSessions } = useMemo ( ( ) => {
10- const today = new Date ( ) ;
11- const cells : { date : string ; count : number ; dayOfWeek : number } [ ] = [ ] ;
12+ export function ActivityHeatmap ( { daily, detailed } : ActivityHeatmapProps ) {
13+ const [ mode , setMode ] = useState < ViewMode > ( "weekday" ) ;
14+ const scrollRef = useRef < HTMLDivElement > ( null ) ;
1215
13- // Find earliest date in data to determine range
14- let earliestDate = today ;
15- data . forEach ( ( _ , dateStr ) => {
16- const d = new Date ( dateStr ) ;
17- if ( d < earliestDate ) earliestDate = d ;
18- } ) ;
16+ const dailyMap = useMemo ( ( ) => new Map ( Object . entries ( daily ) ) , [ daily ] ) ;
17+ const detailedMap = useMemo ( ( ) => new Map ( Object . entries ( detailed ) ) , [ detailed ] ) ;
1918
20- // At least show 12 weeks, or extend to cover all data
21- const minWeeks = 12 ;
22- const msPerDay = 24 * 60 * 60 * 1000 ;
23- const daysSinceEarliest = Math . ceil ( ( today . getTime ( ) - earliestDate . getTime ( ) ) / msPerDay ) ;
24- const weeksNeeded = Math . max ( minWeeks , Math . ceil ( daysSinceEarliest / 7 ) + 1 ) ;
25- const daysToShow = weeksNeeded * 7 ;
19+ // Weekday mode data (existing logic)
20+ const weekdayData = useMemo ( ( ) => {
21+ const today = new Date ( ) ;
22+ const cells : { date : string ; count : number ; dayOfWeek : number } [ ] = [ ] ;
23+ const weeksToShow = 52 ;
24+ const daysToShow = weeksToShow * 7 ;
2625
27- // Generate cells for each day
2826 for ( let i = daysToShow - 1 ; i >= 0 ; i -- ) {
2927 const d = new Date ( today ) ;
3028 d . setDate ( d . getDate ( ) - i ) ;
3129 const dateStr = d . toISOString ( ) . split ( "T" ) [ 0 ] ;
3230 cells . push ( {
3331 date : dateStr ,
34- count : data . get ( dateStr ) || 0 ,
32+ count : dailyMap . get ( dateStr ) || 0 ,
3533 dayOfWeek : d . getDay ( ) ,
3634 } ) ;
3735 }
3836
39- // Group into weeks (columns)
4037 const weeks : typeof cells [ ] = [ ] ;
4138 let currentWeek : typeof cells = [ ] ;
42-
43- // Pad first week if needed
4439 const firstDayOfWeek = cells [ 0 ] ?. dayOfWeek || 0 ;
4540 for ( let i = 0 ; i < firstDayOfWeek ; i ++ ) {
4641 currentWeek . push ( { date : "" , count : 0 , dayOfWeek : i } ) ;
@@ -53,17 +48,73 @@ export function ActivityHeatmap({ data }: ActivityHeatmapProps) {
5348 }
5449 currentWeek . push ( cell ) ;
5550 } ) ;
56- if ( currentWeek . length > 0 ) {
57- weeks . push ( currentWeek ) ;
58- }
51+ if ( currentWeek . length > 0 ) weeks . push ( currentWeek ) ;
52+
53+ const monthLabels : { month : string ; weekIdx : number } [ ] = [ ] ;
54+ let lastMonth = - 1 ;
55+ weeks . forEach ( ( week , weekIdx ) => {
56+ const firstValidCell = week . find ( ( c ) => c . date ) ;
57+ if ( firstValidCell ) {
58+ const month = new Date ( firstValidCell . date ) . getMonth ( ) ;
59+ if ( month !== lastMonth ) {
60+ const monthNames = [ "Jan" , "Feb" , "Mar" , "Apr" , "May" , "Jun" , "Jul" , "Aug" , "Sep" , "Oct" , "Nov" , "Dec" ] ;
61+ monthLabels . push ( { month : monthNames [ month ] , weekIdx } ) ;
62+ lastMonth = month ;
63+ }
64+ }
65+ } ) ;
5966
6067 const maxCount = Math . max ( ...cells . map ( ( c ) => c . count ) , 1 ) ;
6168 const totalSessions = cells . reduce ( ( sum , c ) => sum + c . count , 0 ) ;
6269
63- return { weeks, maxCount, totalSessions } ;
64- } , [ data ] ) ;
70+ return { weeks, maxCount, totalSessions, monthLabels } ;
71+ } , [ dailyMap ] ) ;
72+
73+ // Hour mode data (横轴日期,纵轴0-23小时)
74+ const hourData = useMemo ( ( ) => {
75+ const today = new Date ( ) ;
76+ const daysToShow = 90 ; // 3 months for hour view
77+ const days : { date : string ; hours : number [ ] } [ ] = [ ] ;
78+
79+ for ( let i = daysToShow - 1 ; i >= 0 ; i -- ) {
80+ const d = new Date ( today ) ;
81+ d . setDate ( d . getDate ( ) - i ) ;
82+ const dateStr = d . toISOString ( ) . split ( "T" ) [ 0 ] ;
83+ const hours : number [ ] = [ ] ;
84+ for ( let h = 0 ; h < 24 ; h ++ ) {
85+ const key = `${ dateStr } :${ h . toString ( ) . padStart ( 2 , "0" ) } ` ;
86+ hours . push ( detailedMap . get ( key ) || 0 ) ;
87+ }
88+ days . push ( { date : dateStr , hours } ) ;
89+ }
90+
91+ // Month labels for hour view
92+ const monthLabels : { month : string ; dayIdx : number } [ ] = [ ] ;
93+ let lastMonth = - 1 ;
94+ days . forEach ( ( day , dayIdx ) => {
95+ const month = new Date ( day . date ) . getMonth ( ) ;
96+ if ( month !== lastMonth ) {
97+ const monthNames = [ "Jan" , "Feb" , "Mar" , "Apr" , "May" , "Jun" , "Jul" , "Aug" , "Sep" , "Oct" , "Nov" , "Dec" ] ;
98+ monthLabels . push ( { month : monthNames [ month ] , dayIdx } ) ;
99+ lastMonth = month ;
100+ }
101+ } ) ;
102+
103+ const allCounts = days . flatMap ( ( d ) => d . hours ) ;
104+ const maxCount = Math . max ( ...allCounts , 1 ) ;
105+ const totalSessions = allCounts . reduce ( ( sum , c ) => sum + c , 0 ) ;
65106
66- const getColorClass = ( count : number ) : string => {
107+ return { days, maxCount, totalSessions, monthLabels } ;
108+ } , [ detailedMap ] ) ;
109+
110+ // Scroll to right on mount/mode change
111+ useEffect ( ( ) => {
112+ if ( scrollRef . current ) {
113+ scrollRef . current . scrollLeft = scrollRef . current . scrollWidth ;
114+ }
115+ } , [ mode , weekdayData , hourData ] ) ;
116+
117+ const getColorClass = ( count : number , maxCount : number ) : string => {
67118 if ( count === 0 ) return "bg-muted/30" ;
68119 const ratio = count / maxCount ;
69120 if ( ratio < 0.25 ) return "bg-primary/20" ;
@@ -72,44 +123,139 @@ export function ActivityHeatmap({ data }: ActivityHeatmapProps) {
72123 return "bg-primary" ;
73124 } ;
74125
126+ const cellSize = 11 ;
127+ const cellGap = 3 ;
75128 const weekLabels = [ "Sun" , "Mon" , "Tue" , "Wed" , "Thu" , "Fri" , "Sat" ] ;
129+ const hourLabels = [ "0" , "3" , "6" , "9" , "12" , "15" , "18" , "21" ] ;
130+
131+ const totalSessions = mode === "weekday" ? weekdayData . totalSessions : hourData . totalSessions ;
76132
77133 return (
78- < div className = "space-y-3 " >
134+ < div className = "space-y-2 " >
79135 < div className = "flex items-center justify-between" >
80- < span className = "text-xs text-muted-foreground uppercase tracking-wide" >
81- Activity
82- </ span >
136+ < div className = "flex items-center gap-2" >
137+ < span className = "text-xs text-muted-foreground uppercase tracking-wide" >
138+ Activity
139+ </ span >
140+ { /* Mode toggle */ }
141+ < div className = "flex rounded-md border border-border/60 overflow-hidden" >
142+ < button
143+ onClick = { ( ) => setMode ( "weekday" ) }
144+ className = { `px-2 py-0.5 text-[10px] transition-colors ${
145+ mode === "weekday"
146+ ? "bg-primary text-primary-foreground"
147+ : "bg-transparent text-muted-foreground hover:bg-accent"
148+ } `}
149+ >
150+ Week
151+ </ button >
152+ < button
153+ onClick = { ( ) => setMode ( "hour" ) }
154+ className = { `px-2 py-0.5 text-[10px] transition-colors ${
155+ mode === "hour"
156+ ? "bg-primary text-primary-foreground"
157+ : "bg-transparent text-muted-foreground hover:bg-accent"
158+ } `}
159+ >
160+ Hour
161+ </ button >
162+ </ div >
163+ </ div >
83164 < span className = "text-xs text-muted-foreground" >
84165 { totalSessions . toLocaleString ( ) } chats
85166 </ span >
86167 </ div >
87168
88169 < div className = "flex gap-1" >
89- { /* Week day labels */ }
90- < div className = "flex flex-col gap-[3px] text-[10px] text-muted-foreground/70 pr-1" >
91- { weekLabels . map ( ( label , i ) => (
92- < div key = { i } className = "h-[11px] flex items-center" >
93- { i % 2 === 1 ? label : "" }
94- </ div >
95- ) ) }
170+ { /* Y-axis labels */ }
171+ < div className = "flex flex-col gap-[3px] text-[10px] text-muted-foreground/70 pr-1 pt-4" >
172+ { mode === "weekday"
173+ ? weekLabels . map ( ( label , i ) => (
174+ < div key = { i } className = "h-[11px] flex items-center" >
175+ { i % 2 === 1 ? label : "" }
176+ </ div >
177+ ) )
178+ : Array . from ( { length : 24 } , ( _ , i ) => (
179+ < div key = { i } className = "h-[11px] flex items-center justify-end pr-1" >
180+ { hourLabels . includes ( i . toString ( ) ) ? `${ i } h` : "" }
181+ </ div >
182+ ) ) }
96183 </ div >
97184
98- { /* Heatmap grid */ }
99- < div className = "flex gap-[3px] overflow-x-auto" >
100- { weeks . map ( ( week , weekIdx ) => (
101- < div key = { weekIdx } className = "flex flex-col gap-[3px]" >
102- { week . map ( ( cell , dayIdx ) => (
103- < div
104- key = { dayIdx }
105- className = { `w-[11px] h-[11px] rounded-sm ${
106- cell . date ? getColorClass ( cell . count ) : "bg-transparent"
107- } `}
108- title = { cell . date ? `${ cell . date } : ${ cell . count } chats` : "" }
109- />
110- ) ) }
111- </ div >
112- ) ) }
185+ { /* Scrollable heatmap */ }
186+ < div
187+ ref = { scrollRef }
188+ className = "flex-1 overflow-x-auto scrollbar-thin scrollbar-thumb-border scrollbar-track-transparent"
189+ >
190+ { mode === "weekday" ? (
191+ < >
192+ { /* Month labels */ }
193+ < div className = "flex h-4 mb-1" style = { { gap : `${ cellGap } px` } } >
194+ { weekdayData . weeks . map ( ( _ , weekIdx ) => {
195+ const label = weekdayData . monthLabels . find ( ( m ) => m . weekIdx === weekIdx ) ;
196+ return (
197+ < div
198+ key = { weekIdx }
199+ className = "text-[10px] text-muted-foreground/70"
200+ style = { { width : `${ cellSize } px` , flexShrink : 0 } }
201+ >
202+ { label ?. month || "" }
203+ </ div >
204+ ) ;
205+ } ) }
206+ </ div >
207+ { /* Grid */ }
208+ < div className = "flex" style = { { gap : `${ cellGap } px` } } >
209+ { weekdayData . weeks . map ( ( week , weekIdx ) => (
210+ < div key = { weekIdx } className = "flex flex-col" style = { { gap : `${ cellGap } px` } } >
211+ { week . map ( ( cell , dayIdx ) => (
212+ < div
213+ key = { dayIdx }
214+ className = { `rounded-sm cursor-default ${
215+ cell . date ? getColorClass ( cell . count , weekdayData . maxCount ) : "bg-transparent"
216+ } `}
217+ style = { { width : `${ cellSize } px` , height : `${ cellSize } px` } }
218+ title = { cell . date ? `${ cell . date } : ${ cell . count } chats` : "" }
219+ />
220+ ) ) }
221+ </ div >
222+ ) ) }
223+ </ div >
224+ </ >
225+ ) : (
226+ < >
227+ { /* Month labels for hour view */ }
228+ < div className = "flex h-4 mb-1" style = { { gap : `${ cellGap } px` } } >
229+ { hourData . days . map ( ( _ , dayIdx ) => {
230+ const label = hourData . monthLabels . find ( ( m ) => m . dayIdx === dayIdx ) ;
231+ return (
232+ < div
233+ key = { dayIdx }
234+ className = "text-[10px] text-muted-foreground/70"
235+ style = { { width : `${ cellSize } px` , flexShrink : 0 } }
236+ >
237+ { label ?. month || "" }
238+ </ div >
239+ ) ;
240+ } ) }
241+ </ div >
242+ { /* Grid: each column is a day, each row is an hour */ }
243+ < div className = "flex" style = { { gap : `${ cellGap } px` } } >
244+ { hourData . days . map ( ( day , dayIdx ) => (
245+ < div key = { dayIdx } className = "flex flex-col" style = { { gap : `${ cellGap } px` } } >
246+ { day . hours . map ( ( count , hourIdx ) => (
247+ < div
248+ key = { hourIdx }
249+ className = { `rounded-sm cursor-default ${ getColorClass ( count , hourData . maxCount ) } ` }
250+ style = { { width : `${ cellSize } px` , height : `${ cellSize } px` } }
251+ title = { `${ day . date } ${ hourIdx } :00 - ${ count } chats` }
252+ />
253+ ) ) }
254+ </ div >
255+ ) ) }
256+ </ div >
257+ </ >
258+ ) }
113259 </ div >
114260 </ div >
115261
0 commit comments