1- import { useMemo } from "react" ;
1+ import { useMemo , useState } from "react" ;
2+ import { useAtom } from "jotai" ;
23import {
34 LineChart ,
45 Line ,
@@ -8,60 +9,125 @@ import {
89 ResponsiveContainer ,
910 Legend ,
1011} from "recharts" ;
12+ import { commandRangeAtom , commandModeAtom } from "@/store" ;
1113
1214interface CommandTrendChartProps {
1315 /** Map of command_name -> { week_key -> count } */
1416 data : Record < string , Record < string , number > > ;
1517 weeks ?: number ;
1618}
1719
18- // Color palette for commands (using primary variations)
20+ // Color palette for commands
1921const COLORS = [
20- "hsl(var(--primary))" ,
21- "hsl(var(--primary) / 0.7) " ,
22- "hsl(var(--primary) / 0.5) " ,
23- "hsl(25, 50%, 55%) " ,
24- "hsl(25, 40%, 65%)" ,
25- "hsl(25, 30%, 75%)" ,
26- "hsl(200, 40%, 55%)" ,
27- "hsl(160, 40%, 50%)" ,
22+ "#CC785C" , // primary (陶土色)
23+ "#D4896D " ,
24+ "#DC9A7E " ,
25+ "#B86A4E " ,
26+ "#5B8A72" , // 绿
27+ "#4A7B9D" , // 蓝
28+ "#8B7355" , // 棕
29+ "#7D6B8A" , // 紫
2830] ;
2931
30- const MAX_COMMANDS = 8 ; // Only show top N commands in chart
32+ const MAX_COMMANDS = 8 ;
33+
34+ interface HoverData {
35+ label : string ;
36+ items : { name : string ; value : number ; color : string } [ ] ;
37+ }
38+
39+ // Get ISO week number
40+ function getISOWeek ( date : Date ) : { year : number ; week : number } {
41+ const d = new Date ( Date . UTC ( date . getFullYear ( ) , date . getMonth ( ) , date . getDate ( ) ) ) ;
42+ const dayNum = d . getUTCDay ( ) || 7 ;
43+ d . setUTCDate ( d . getUTCDate ( ) + 4 - dayNum ) ;
44+ const yearStart = new Date ( Date . UTC ( d . getUTCFullYear ( ) , 0 , 1 ) ) ;
45+ const weekNo = Math . ceil ( ( ( ( d . getTime ( ) - yearStart . getTime ( ) ) / 86400000 ) + 1 ) / 7 ) ;
46+ return { year : d . getUTCFullYear ( ) , week : weekNo } ;
47+ }
48+
49+ type ChartMode = "weekly" | "cumulative" ;
50+ type TimeRange = "1m" | "3m" | "all" ;
51+
52+ const RANGE_WEEKS : Record < TimeRange , number | null > = {
53+ "1m" : 5 , // ~1 month
54+ "3m" : 13 , // ~3 months
55+ "all" : null ,
56+ } ;
57+
58+ export function CommandTrendChart ( { data, weeks = 13 } : CommandTrendChartProps ) {
59+ const [ hoverData , setHoverData ] = useState < HoverData | null > ( null ) ;
60+ const [ mode , setMode ] = useAtom ( commandModeAtom ) ;
61+ const [ range , setRange ] = useAtom ( commandRangeAtom ) ;
3162
32- export function CommandTrendChart ( { data, weeks = 12 } : CommandTrendChartProps ) {
3363 const { chartData, commands, totalCommands } = useMemo ( ( ) => {
34- // Collect all week keys from the actual data
35- const weekKeySet = new Set < string > ( ) ;
36- Object . values ( data ) . forEach ( ( weekData ) => {
37- Object . keys ( weekData ) . forEach ( ( week ) => weekKeySet . add ( week ) ) ;
38- } ) ;
64+ // Generate week keys based on range
65+ const weekKeys : string [ ] = [ ] ;
66+ const rangeWeeks = RANGE_WEEKS [ range ] ;
3967
40- // Sort week keys chronologically and take last N weeks
41- const allWeekKeys = Array . from ( weekKeySet ) . sort ( ) ;
42- const weekKeys = allWeekKeys . slice ( - weeks ) ;
68+ if ( rangeWeeks === null ) {
69+ // "all" mode: extract all unique weeks from data, sorted
70+ const allWeeks = new Set < string > ( ) ;
71+ Object . values ( data ) . forEach ( weekData => {
72+ Object . keys ( weekData ) . forEach ( w => allWeeks . add ( w ) ) ;
73+ } ) ;
74+ weekKeys . push ( ...Array . from ( allWeeks ) . sort ( ) ) ;
75+ } else {
76+ // Fixed range: generate week keys for the last N weeks
77+ const now = new Date ( ) ;
78+ for ( let i = rangeWeeks - 1 ; i >= 0 ; i -- ) {
79+ const d = new Date ( now ) ;
80+ d . setDate ( d . getDate ( ) - i * 7 ) ;
81+ const { year, week } = getISOWeek ( d ) ;
82+ weekKeys . push ( `${ year } -W${ week . toString ( ) . padStart ( 2 , "0" ) } ` ) ;
83+ }
84+ }
4385
44- // Get all commands and sort by total usage
86+ // Get all commands and sort by total usage within the selected range
87+ const weekKeySet = new Set ( weekKeys ) ;
4588 const commandTotals : [ string , number ] [ ] = Object . entries ( data ) . map ( ( [ cmd , weekData ] ) => [
4689 cmd ,
47- Object . values ( weekData ) . reduce ( ( sum , count ) => sum + count , 0 ) ,
90+ Object . entries ( weekData )
91+ . filter ( ( [ w ] ) => weekKeySet . has ( w ) )
92+ . reduce ( ( sum , [ , count ] ) => sum + count , 0 ) ,
4893 ] ) ;
4994 commandTotals . sort ( ( a , b ) => b [ 1 ] - a [ 1 ] ) ;
5095 const totalCommands = commandTotals . length ;
5196 // Only take top N commands for display
5297 const commands = commandTotals . slice ( 0 , MAX_COMMANDS ) . map ( ( [ cmd ] ) => cmd ) ;
5398
54- // Build chart data
55- const chartData = weekKeys . map ( ( week ) => {
56- const point : Record < string , string | number > = { week : week . replace ( / ^ \d { 4 } - W / , "W" ) } ;
57- commands . forEach ( ( cmd ) => {
58- point [ cmd ] = data [ cmd ] ?. [ week ] || 0 ;
59- } ) ;
99+ // Build chart data based on mode
100+ const chartData = weekKeys . map ( ( week , idx ) => {
101+ const isCurrentWeek = idx === weekKeys . length - 1 ;
102+ const point : Record < string , string | number | null > = {
103+ week : week . replace ( / ^ \d { 4 } - W / , "W" ) ,
104+ } ;
105+
106+ if ( mode === "cumulative" ) {
107+ // Cumulative: sum all weeks up to this point
108+ commands . forEach ( ( cmd ) => {
109+ let cumulative = 0 ;
110+ for ( let i = 0 ; i <= idx ; i ++ ) {
111+ cumulative += data [ cmd ] ?. [ weekKeys [ i ] ] || 0 ;
112+ }
113+ point [ cmd ] = cumulative ;
114+ } ) ;
115+ } else {
116+ // Weekly: show each week's data, current week as dot
117+ commands . forEach ( ( cmd ) => {
118+ if ( isCurrentWeek ) {
119+ point [ cmd ] = null ; // Break the line
120+ point [ `${ cmd } _current` ] = data [ cmd ] ?. [ week ] || 0 ;
121+ } else {
122+ point [ cmd ] = data [ cmd ] ?. [ week ] || 0 ;
123+ }
124+ } ) ;
125+ }
60126 return point ;
61127 } ) ;
62128
63129 return { chartData, commands, totalCommands } ;
64- } , [ data , weeks ] ) ;
130+ } , [ data , range , mode ] ) ;
65131
66132 if ( commands . length === 0 ) {
67133 return (
@@ -74,17 +140,63 @@ export function CommandTrendChart({ data, weeks = 12 }: CommandTrendChartProps)
74140 return (
75141 < div className = "space-y-2" >
76142 < div className = "flex items-center justify-between" >
77- < span className = "text-xs text-muted-foreground uppercase tracking-wide" >
78- Command Trends
79- </ span >
143+ < div className = "flex items-center gap-2" >
144+ < span className = "text-xs text-muted-foreground uppercase tracking-wide" >
145+ Command Trends
146+ </ span >
147+ { /* Time range toggle */ }
148+ < div className = "flex rounded-md border border-border/60 overflow-hidden" >
149+ { ( [ "1m" , "3m" , "all" ] as TimeRange [ ] ) . map ( ( r ) => (
150+ < button
151+ key = { r }
152+ onClick = { ( ) => setRange ( r ) }
153+ className = { `px-2 py-0.5 text-[10px] transition-colors ${
154+ range === r
155+ ? "bg-primary text-primary-foreground"
156+ : "bg-transparent text-muted-foreground hover:bg-accent"
157+ } `}
158+ >
159+ { r === "1m" ? "1M" : r === "3m" ? "3M" : "All" }
160+ </ button >
161+ ) ) }
162+ </ div >
163+ { /* Mode toggle */ }
164+ < div className = "flex rounded-md border border-border/60 overflow-hidden" >
165+ < button
166+ onClick = { ( ) => setMode ( "weekly" ) }
167+ className = { `px-2 py-0.5 text-[10px] transition-colors ${
168+ mode === "weekly"
169+ ? "bg-primary text-primary-foreground"
170+ : "bg-transparent text-muted-foreground hover:bg-accent"
171+ } `}
172+ >
173+ Weekly
174+ </ button >
175+ < button
176+ onClick = { ( ) => setMode ( "cumulative" ) }
177+ className = { `px-2 py-0.5 text-[10px] transition-colors ${
178+ mode === "cumulative"
179+ ? "bg-primary text-primary-foreground"
180+ : "bg-transparent text-muted-foreground hover:bg-accent"
181+ } `}
182+ >
183+ Cumulative
184+ </ button >
185+ </ div >
186+ </ div >
80187 < span className = "text-xs text-muted-foreground" >
81188 Top { commands . length } of { totalCommands }
82189 </ span >
83190 </ div >
84191
85- < div className = "h-40" >
86- < ResponsiveContainer width = "100%" height = "100%" >
87- < LineChart data = { chartData } margin = { { top : 5 , right : 5 , bottom : 5 , left : - 20 } } >
192+ { /* Chart */ }
193+ < div className = "h-36 w-full min-w-0" >
194+ < ResponsiveContainer width = "100%" height = { 144 } minWidth = { 0 } >
195+ < LineChart
196+ data = { chartData }
197+ margin = { { top : 5 , right : 5 , bottom : 5 , left : - 20 } }
198+ onMouseLeave = { ( ) => setHoverData ( null ) }
199+ >
88200 < XAxis
89201 dataKey = "week"
90202 tick = { { fontSize : 10 , fill : "hsl(var(--muted-foreground))" } }
@@ -99,13 +211,32 @@ export function CommandTrendChart({ data, weeks = 12 }: CommandTrendChartProps)
99211 allowDecimals = { false }
100212 />
101213 < Tooltip
102- contentStyle = { {
103- backgroundColor : "hsl(var(--card))" ,
104- border : "1px solid hsl(var(--border))" ,
105- borderRadius : "8px" ,
106- fontSize : "12px" ,
214+ content = { ( { active, payload, label } ) => {
215+ // Update external state when tooltip is active
216+ if ( active && payload && payload . length > 0 ) {
217+ // Merge _current entries with main entries
218+ const merged = new Map < string , { value : number ; color : string } > ( ) ;
219+ payload . forEach ( ( p ) => {
220+ const name = ( p . name as string ) . replace ( / _ c u r r e n t $ / , "" ) ;
221+ const value = ( p . value as number ) ?? 0 ;
222+ const existing = merged . get ( name ) ;
223+ if ( ! existing || value > 0 ) {
224+ merged . set ( name , { value, color : ( p . color as string ) || "#999" } ) ;
225+ }
226+ } ) ;
227+ const newData : HoverData = {
228+ label : label as string ,
229+ items : Array . from ( merged . entries ( ) ) . map ( ( [ name , { value, color } ] ) => ( {
230+ name,
231+ value,
232+ color,
233+ } ) ) ,
234+ } ;
235+ // Use setTimeout to avoid setState during render
236+ setTimeout ( ( ) => setHoverData ( newData ) , 0 ) ;
237+ }
238+ return null ; // Don't render tooltip
107239 } }
108- labelStyle = { { color : "hsl(var(--foreground))" } }
109240 />
110241 < Legend
111242 wrapperStyle = { { fontSize : "10px" , paddingTop : "8px" } }
@@ -120,11 +251,48 @@ export function CommandTrendChart({ data, weeks = 12 }: CommandTrendChartProps)
120251 strokeWidth = { 1.5 }
121252 dot = { false }
122253 activeDot = { { r : 3 } }
254+ connectNulls = { false }
255+ />
256+ ) ) }
257+ { /* Current week points - shown as dots only in weekly mode */ }
258+ { mode === "weekly" && commands . map ( ( cmd , i ) => (
259+ < Line
260+ key = { `${ cmd } _current` }
261+ type = "monotone"
262+ dataKey = { `${ cmd } _current` }
263+ stroke = { COLORS [ i % COLORS . length ] }
264+ strokeWidth = { 0 }
265+ dot = { { r : 3 , fill : COLORS [ i % COLORS . length ] , strokeWidth : 0 } }
266+ activeDot = { { r : 4 } }
267+ legendType = "none"
123268 />
124269 ) ) }
125270 </ LineChart >
126271 </ ResponsiveContainer >
127272 </ div >
273+
274+ { /* Details below chart */ }
275+ < div className = "min-h-[60px] pt-2 border-t border-border/40" >
276+ { hoverData ? (
277+ < div className = "flex flex-wrap gap-x-4 gap-y-1" >
278+ < span className = "text-sm font-medium w-full mb-1" > { hoverData . label } </ span >
279+ { [ ...hoverData . items ] . sort ( ( a , b ) => b . value - a . value ) . map ( ( entry , i ) => (
280+ < div key = { i } className = "flex items-center gap-1.5 text-xs" >
281+ < span
282+ className = "w-2 h-2 rounded-full flex-shrink-0"
283+ style = { { backgroundColor : entry . color } }
284+ />
285+ < span className = "text-muted-foreground" > { entry . name } :</ span >
286+ < span className = "font-medium" > { entry . value } </ span >
287+ </ div >
288+ ) ) }
289+ </ div >
290+ ) : (
291+ < div className = "text-xs text-muted-foreground italic" >
292+ Hover chart to see week details
293+ </ div >
294+ ) }
295+ </ div >
128296 </ div >
129297 ) ;
130298}
0 commit comments