1+ import { useMemo } from "react" ;
2+ import { cn } from "@/lib/utils" ;
3+ import { TranscriptionHistory } from "@/types" ;
4+
5+ interface ActivityGraphProps {
6+ history : TranscriptionHistory [ ] ;
7+ weeks ?: number ;
8+ }
9+
10+ export function ActivityGraph ( { history, weeks = 12 } : ActivityGraphProps ) {
11+ const activityData = useMemo ( ( ) => {
12+ const today = new Date ( ) ;
13+ today . setHours ( 23 , 59 , 59 , 999 ) ;
14+
15+ // Calculate the grid: always show 'weeks' weeks x 7 days
16+ const weeksData : number [ ] [ ] = [ ] ;
17+
18+ // Start from 'weeks' weeks ago, on a Sunday
19+ const startDate = new Date ( today ) ;
20+ startDate . setDate ( startDate . getDate ( ) - ( weeks * 7 ) + 1 ) ;
21+ // Adjust to Sunday
22+ const dayOfWeek = startDate . getDay ( ) ;
23+ if ( dayOfWeek !== 0 ) {
24+ startDate . setDate ( startDate . getDate ( ) - dayOfWeek ) ;
25+ }
26+ startDate . setHours ( 0 , 0 , 0 , 0 ) ;
27+
28+ // Create a map for quick lookup of history data
29+ const historyMap = new Map < string , number > ( ) ;
30+ history . forEach ( item => {
31+ const date = new Date ( item . timestamp ) ;
32+ const dateKey = date . toISOString ( ) . split ( 'T' ) [ 0 ] ;
33+ historyMap . set ( dateKey , ( historyMap . get ( dateKey ) || 0 ) + 1 ) ;
34+ } ) ;
35+
36+ // Build the grid week by week
37+ for ( let w = 0 ; w < weeks ; w ++ ) {
38+ const week : number [ ] = [ ] ;
39+ for ( let d = 0 ; d < 7 ; d ++ ) {
40+ const currentDate = new Date ( startDate ) ;
41+ currentDate . setDate ( startDate . getDate ( ) + ( w * 7 ) + d ) ;
42+
43+ // Check if this date is in the future
44+ if ( currentDate > today ) {
45+ week . push ( 0 ) ; // Future dates shown as empty
46+ } else {
47+ const dateKey = currentDate . toISOString ( ) . split ( 'T' ) [ 0 ] ;
48+ week . push ( historyMap . get ( dateKey ) || 0 ) ;
49+ }
50+ }
51+ weeksData . push ( week ) ;
52+ }
53+
54+ // Calculate max count for intensity levels
55+ const maxCount = Math . max ( 1 , ...Array . from ( historyMap . values ( ) ) ) ;
56+
57+ return { weeksData, maxCount } ;
58+ } , [ history , weeks ] ) ;
59+
60+ const getIntensityClass = ( count : number , maxCount : number ) => {
61+ if ( count === 0 ) return "bg-gray-200 dark:bg-gray-800 hover:bg-gray-300 dark:hover:bg-gray-700" ; // Very visible gray!
62+
63+ const intensity = maxCount > 0 ? count / maxCount : 0 ;
64+ if ( intensity > 0.75 ) return "bg-primary hover:bg-primary/90" ;
65+ if ( intensity > 0.5 ) return "bg-primary/75 hover:bg-primary/65" ;
66+ if ( intensity > 0.25 ) return "bg-primary/50 hover:bg-primary/40" ;
67+ return "bg-primary/25 hover:bg-primary/20" ;
68+ } ;
69+
70+ const monthLabels = useMemo ( ( ) => {
71+ const labels : { month : string ; position : number } [ ] = [ ] ;
72+ const today = new Date ( ) ;
73+ const startDate = new Date ( today ) ;
74+ startDate . setDate ( startDate . getDate ( ) - ( weeks * 7 ) + 1 ) ;
75+
76+ let currentMonth = - 1 ;
77+ for ( let week = 0 ; week < weeks ; week ++ ) {
78+ const weekDate = new Date ( startDate ) ;
79+ weekDate . setDate ( weekDate . getDate ( ) + ( week * 7 ) ) ;
80+ const month = weekDate . getMonth ( ) ;
81+
82+ if ( month !== currentMonth ) {
83+ currentMonth = month ;
84+ labels . push ( {
85+ month : weekDate . toLocaleDateString ( 'en-US' , { month : 'short' } ) ,
86+ position : week
87+ } ) ;
88+ }
89+ }
90+
91+ return labels ;
92+ } , [ weeks ] ) ;
93+
94+ const dayLabels = [ 'S' , 'M' , 'T' , 'W' , 'T' , 'F' , 'S' ] ;
95+
96+ return (
97+ < div className = "space-y-2" >
98+ < div className = "flex items-center justify-between mb-2" >
99+ < h3 className = "text-sm font-medium" > Activity</ h3 >
100+ < div className = "flex items-center gap-2 text-xs text-muted-foreground" >
101+ < span > Less</ span >
102+ < div className = "flex gap-1" >
103+ < div className = "w-3 h-3 rounded-sm bg-gray-200 dark:bg-gray-800" />
104+ < div className = "w-3 h-3 rounded-sm bg-primary/25" />
105+ < div className = "w-3 h-3 rounded-sm bg-primary/50" />
106+ < div className = "w-3 h-3 rounded-sm bg-primary/75" />
107+ < div className = "w-3 h-3 rounded-sm bg-primary" />
108+ </ div >
109+ < span > More</ span >
110+ </ div >
111+ </ div >
112+
113+ < div className = "flex gap-2" >
114+ { /* Day labels */ }
115+ < div className = "flex flex-col gap-[2px] pr-1" >
116+ < div className = "h-3" /> { /* Spacer for month labels */ }
117+ { dayLabels . map ( ( day , index ) => (
118+ < div key = { index } className = "h-3 text-[10px] text-muted-foreground flex items-center" >
119+ { index % 2 === 1 ? day : '' }
120+ </ div >
121+ ) ) }
122+ </ div >
123+
124+ < div className = "flex-1" >
125+ { /* Month labels */ }
126+ < div className = "flex h-3 mb-1 relative" >
127+ { monthLabels . map ( ( { month, position } ) => (
128+ < div
129+ key = { `${ month } -${ position } ` }
130+ className = "absolute text-[10px] text-muted-foreground"
131+ style = { { left : `${ ( position / weeks ) * 100 } %` } }
132+ >
133+ { month }
134+ </ div >
135+ ) ) }
136+ </ div >
137+
138+ { /* Activity grid */ }
139+ < div className = "flex gap-[2px]" >
140+ { activityData . weeksData . map ( ( week , weekIndex ) => (
141+ < div key = { weekIndex } className = "flex flex-col gap-[2px]" >
142+ { week . map ( ( count , dayIndex ) => {
143+ const date = new Date ( ) ;
144+ date . setDate ( date . getDate ( ) - ( ( weeks - weekIndex - 1 ) * 7 ) - ( 6 - dayIndex ) ) ;
145+ const dateStr = date . toLocaleDateString ( 'en-US' , {
146+ month : 'short' ,
147+ day : 'numeric' ,
148+ year : 'numeric'
149+ } ) ;
150+
151+ return (
152+ < div
153+ key = { dayIndex }
154+ className = { cn (
155+ "w-3 h-3 rounded-sm transition-colors cursor-pointer" ,
156+ getIntensityClass ( count , activityData . maxCount )
157+ ) }
158+ title = { `${ count } transcription${ count !== 1 ? 's' : '' } on ${ dateStr } ` }
159+ />
160+ ) ;
161+ } ) }
162+ </ div >
163+ ) ) }
164+ </ div >
165+ </ div >
166+ </ div >
167+
168+ < div className = "text-xs text-muted-foreground" >
169+ { history . length } total transcriptions in the last { weeks } weeks
170+ </ div >
171+ </ div >
172+ ) ;
173+ }
0 commit comments