1- import { useEffect , useState } from "react" ;
1+ import { useEffect , useLayoutEffect , useRef , useState } from "react" ;
22import { Clock , Coins , FolderGit2 , MessagesSquare } from "lucide-react" ;
33import { type AggregateStats , getAggregateStats } from "@/lib/api" ;
44import {
@@ -30,27 +30,39 @@ function formatDuration(sec: number): string {
3030/* Daily Usage Chart (SVG line chart) */
3131/* ------------------------------------------------------------------ */
3232
33- const CHART_WIDTH = 600 ;
34- const CHART_HEIGHT = 140 ;
35- const CHART_PAD_X = 36 ;
36- const CHART_PAD_TOP = 14 ;
37- const CHART_PAD_BOTTOM = 26 ;
33+ const DU_HEIGHT = 220 ;
34+ const DU_PAD_L = 34 ;
35+ const DU_PAD_R = 16 ;
36+ const DU_PAD_T = 16 ;
37+ const DU_PAD_B = 28 ;
38+ const DU_GRID_STEPS = 4 ;
3839
3940function DailyUsageChart ( { daily } : { daily : AggregateStats [ "daily_usage" ] } ) {
41+ const ref = useRef < HTMLDivElement > ( null ) ;
42+ const [ width , setWidth ] = useState ( 720 ) ;
43+
44+ useLayoutEffect ( ( ) => {
45+ const el = ref . current ;
46+ if ( ! el ) return ;
47+ const update = ( ) => setWidth ( el . clientWidth ) ;
48+ update ( ) ;
49+ const ro = new ResizeObserver ( update ) ;
50+ ro . observe ( el ) ;
51+ return ( ) => ro . disconnect ( ) ;
52+ } , [ ] ) ;
53+
4054 if ( daily . length === 0 ) return null ;
4155
4256 const maxSessions = Math . max ( 1 , ...daily . map ( ( d ) => d . sessions ) ) ;
4357 const maxTurns = Math . max ( 1 , ...daily . map ( ( d ) => d . turns ) ) ;
4458
45- const innerW = CHART_WIDTH - CHART_PAD_X * 2 ;
46- const innerH = CHART_HEIGHT - CHART_PAD_TOP - CHART_PAD_BOTTOM ;
59+ const plotW = Math . max ( 1 , width - DU_PAD_L - DU_PAD_R ) ;
60+ const plotH = DU_HEIGHT - DU_PAD_T - DU_PAD_B ;
4761
4862 const toX = ( i : number ) =>
49- CHART_PAD_X +
50- ( daily . length > 1 ? ( i / ( daily . length - 1 ) ) * innerW : innerW / 2 ) ;
51- const toYSessions = ( v : number ) =>
52- CHART_PAD_TOP + ( 1 - v / maxSessions ) * innerH ;
53- const toYTurns = ( v : number ) => CHART_PAD_TOP + ( 1 - v / maxTurns ) * innerH ;
63+ DU_PAD_L + ( daily . length > 1 ? ( i / ( daily . length - 1 ) ) * plotW : plotW / 2 ) ;
64+ const toYSessions = ( v : number ) => DU_PAD_T + ( 1 - v / maxSessions ) * plotH ;
65+ const toYTurns = ( v : number ) => DU_PAD_T + ( 1 - v / maxTurns ) * plotH ;
5466
5567 const sessionsPath = daily
5668 . map ( ( d , i ) => `${ i === 0 ? "M" : "L" } ${ toX ( i ) } ${ toYSessions ( d . sessions ) } ` )
@@ -63,18 +75,7 @@ function DailyUsageChart({ daily }: { daily: AggregateStats["daily_usage"] }) {
6375 ` L ${ toX ( daily . length - 1 ) } ${ toYSessions ( 0 ) } ` +
6476 ` L ${ toX ( 0 ) } ${ toYSessions ( 0 ) } Z` ;
6577
66- const baselineY = CHART_PAD_TOP + innerH ;
67-
68- // X-axis labels: ~5 evenly spaced dates
69- const labelCount = Math . min ( 5 , daily . length ) ;
70- const labelIndices : number [ ] = [ ] ;
71- if ( labelCount <= 1 ) {
72- if ( daily . length > 0 ) labelIndices . push ( 0 ) ;
73- } else {
74- for ( let i = 0 ; i < labelCount ; i ++ ) {
75- labelIndices . push ( Math . round ( ( i / ( labelCount - 1 ) ) * ( daily . length - 1 ) ) ) ;
76- }
77- }
78+ const labelStep = Math . max ( 1 , Math . ceil ( daily . length / 6 ) ) ;
7879
7980 return (
8081 < Card >
@@ -94,91 +95,93 @@ function DailyUsageChart({ daily }: { daily: AggregateStats["daily_usage"] }) {
9495 </ span >
9596 </ div >
9697 </ CardHeader >
97- < CardContent className = "pt-2" >
98- < svg
99- viewBox = { `0 0 ${ CHART_WIDTH } ${ CHART_HEIGHT } ` }
100- className = "w-full"
101- style = { { maxHeight : CHART_HEIGHT } }
102- role = "img"
103- aria-label = "Daily sessions and turns over the last 30 days"
104- >
105- { /* Baseline */ }
106- < line
107- x1 = { CHART_PAD_X }
108- y1 = { baselineY }
109- x2 = { CHART_WIDTH - CHART_PAD_X }
110- y2 = { baselineY }
111- className = "stroke-border"
112- strokeWidth = { 1 }
113- />
114-
115- { /* Sessions area + line (accent) */ }
116- < path d = { sessionsArea } className = "fill-primary/[0.08]" />
117- < path
118- d = { sessionsPath }
119- className = "stroke-primary"
120- strokeWidth = { 1.5 }
121- fill = "none"
122- strokeLinejoin = "round"
123- />
124-
125- { /* Turns line (muted dashed) */ }
126- < path
127- d = { turnsPath }
128- className = "stroke-muted-foreground"
129- strokeWidth = { 1.5 }
130- fill = "none"
131- strokeDasharray = "4 3"
132- strokeLinejoin = "round"
133- />
134-
135- { /* Y-axis bounds */ }
136- < text
137- x = { CHART_PAD_X - 6 }
138- y = { CHART_PAD_TOP + 4 }
139- className = "fill-muted-foreground"
140- fontSize = { 8 }
141- textAnchor = "end"
142- >
143- { maxSessions }
144- </ text >
145- < text
146- x = { CHART_PAD_X - 6 }
147- y = { baselineY + 3 }
148- className = "fill-muted-foreground"
149- fontSize = { 8 }
150- textAnchor = "end"
98+ < CardContent className = "pt-3" >
99+ < div ref = { ref } className = "w-full" style = { { height : DU_HEIGHT } } >
100+ < svg
101+ width = { width }
102+ height = { DU_HEIGHT }
103+ role = "img"
104+ aria-label = "Daily sessions and turns over the last 30 days"
151105 >
152- 0
153- </ text >
154-
155- { /* X-axis date labels */ }
156- { labelIndices . map ( ( idx ) => (
157- < text
158- key = { idx }
159- x = { toX ( idx ) }
160- y = { CHART_HEIGHT - 6 }
161- className = "fill-muted-foreground"
162- fontSize = { 8 }
163- textAnchor = "middle"
164- >
165- { daily [ idx ] . date . slice ( 5 ) }
166- </ text >
167- ) ) }
168-
169- { /* Session dots */ }
170- { daily . map ( ( d , i ) =>
171- d . sessions > 0 ? (
172- < circle
173- key = { i }
174- cx = { toX ( i ) }
175- cy = { toYSessions ( d . sessions ) }
176- r = { 1.8 }
177- className = "fill-primary"
178- />
179- ) : null ,
180- ) }
181- </ svg >
106+ { /* Horizontal gridlines + sessions Y labels */ }
107+ { Array . from ( { length : DU_GRID_STEPS + 1 } , ( _ , i ) => {
108+ const v = ( maxSessions / DU_GRID_STEPS ) * i ;
109+ const y = toYSessions ( v ) ;
110+ return (
111+ < g key = { i } >
112+ < line
113+ x1 = { DU_PAD_L }
114+ y1 = { y }
115+ x2 = { width - DU_PAD_R }
116+ y2 = { y }
117+ className = "stroke-border"
118+ strokeWidth = { 1 }
119+ strokeDasharray = { i === 0 ? undefined : "4 4" }
120+ />
121+ < text
122+ x = { DU_PAD_L - 6 }
123+ y = { y + 3 }
124+ className = "fill-muted-foreground"
125+ fontSize = { 9 }
126+ textAnchor = "end"
127+ >
128+ { Math . round ( v ) }
129+ </ text >
130+ </ g >
131+ ) ;
132+ } ) }
133+
134+ { /* Sessions area + line (accent) */ }
135+ < path d = { sessionsArea } className = "fill-primary/[0.08]" />
136+ < path
137+ d = { sessionsPath }
138+ className = "stroke-primary"
139+ strokeWidth = { 2 }
140+ fill = "none"
141+ strokeLinejoin = "round"
142+ strokeLinecap = "round"
143+ />
144+
145+ { /* Turns line (muted dashed) */ }
146+ < path
147+ d = { turnsPath }
148+ className = "stroke-muted-foreground"
149+ strokeWidth = { 1.5 }
150+ fill = "none"
151+ strokeDasharray = "4 3"
152+ strokeLinejoin = "round"
153+ />
154+
155+ { /* X-axis date labels */ }
156+ { daily . map ( ( d , i ) =>
157+ i % labelStep === 0 || i === daily . length - 1 ? (
158+ < text
159+ key = { i }
160+ x = { toX ( i ) }
161+ y = { DU_HEIGHT - 8 }
162+ className = "fill-muted-foreground"
163+ fontSize = { 9 }
164+ textAnchor = "middle"
165+ >
166+ { d . date . slice ( 5 ) }
167+ </ text >
168+ ) : null ,
169+ ) }
170+
171+ { /* Session dots */ }
172+ { daily . map ( ( d , i ) =>
173+ d . sessions > 0 ? (
174+ < circle
175+ key = { i }
176+ cx = { toX ( i ) }
177+ cy = { toYSessions ( d . sessions ) }
178+ r = { 2 }
179+ className = "fill-primary"
180+ />
181+ ) : null ,
182+ ) }
183+ </ svg >
184+ </ div >
182185 </ CardContent >
183186 </ Card >
184187 ) ;
0 commit comments