11import { useState , useMemo } from 'react' ;
2- import { v4 as uuidv4 } from 'uuid' ;
32import { useSelector } from 'react-redux' ;
43import getJobAnalyticsData from './api' ;
54import styles from './jobAnalytics.module.css' ;
65
76function JobAnalytics ( ) {
8- const [ dateFilter , setDateFilter ] = useState ( 'all' ) ;
7+ const { darkMode } = useSelector ( state => state . theme ) ;
8+
9+ // Date range (new)
10+ const [ startDate , setStartDate ] = useState ( '' ) ;
11+ const [ endDate , setEndDate ] = useState ( '' ) ;
12+
13+ // Role filter
914 const [ selectedRole , setSelectedRole ] = useState ( 'all' ) ;
10- const [ hoveredBar , setHoveredBar ] = useState ( null ) ;
11- const darkMode = useSelector ( state => state . theme . darkMode ) ;
1215 const rawData = getJobAnalyticsData ( ) ;
13- const processedData = useMemo ( ( ) => {
16+
17+ // Roles list for the dropdown (includes "all")
18+ const roles = useMemo ( ( ) => {
19+ const r = Array . from ( new Set ( rawData . map ( r => r . role ) ) ) . sort ( ( a , b ) => a . localeCompare ( b ) ) ;
20+ return [ 'all' , ...r ] ;
21+ } , [ rawData ] ) ;
22+
23+ const invalidRange = useMemo ( ( ) => {
24+ if ( startDate && endDate ) return new Date ( startDate ) > new Date ( endDate ) ;
25+ return false ;
26+ } , [ startDate , endDate ] ) ;
27+
28+ const { chartData, maxApplications } = useMemo ( ( ) => {
1429 let filtered = [ ...rawData ] ;
15- if ( dateFilter !== 'all' ) {
16- const now = new Date ( ) ;
17- filtered = filtered . filter ( item => {
18- const itemDate = new Date ( item . timestamp ) ;
19- const daysAgo = Math . floor ( ( now - itemDate ) / ( 1000 * 60 * 60 * 24 ) ) ;
20-
21- switch ( dateFilter ) {
22- case 'weekly' :
23- if ( daysAgo <= 7 ) return true ;
24- return false ;
25- case 'monthly' :
26- return daysAgo <= 30 ;
27- case 'yearly' :
28- return daysAgo <= 365 ;
29- default :
30- return true ;
31- }
30+
31+ // Date range filter (range-first)
32+ if ( startDate || endDate ) {
33+ const start = startDate ? new Date ( `${ startDate } T00:00:00` ) : new Date ( '1970-01-01T00:00:00' ) ;
34+ const end = endDate ? new Date ( `${ endDate } T23:59:59` ) : new Date ( ) ;
35+ filtered = filtered . filter ( row => {
36+ const d = new Date ( row . timestamp ) ;
37+ return d >= start && d <= end ;
3238 } ) ;
3339 }
40+
41+ // Role filter
3442 if ( selectedRole !== 'all' ) {
35- filtered = filtered . filter ( item => item . role === selectedRole ) ;
43+ filtered = filtered . filter ( row => row . role === selectedRole ) ;
44+ }
45+
46+ // Group counts per role
47+ const counts = new Map ( ) ;
48+ for ( const row of filtered ) {
49+ counts . set ( row . role , ( counts . get ( row . role ) || 0 ) + 1 ) ;
3650 }
37- const roleGroups = { } ;
38- filtered . forEach ( item => {
39- if ( ! roleGroups [ item . role ] ) {
40- roleGroups [ item . role ] = 0 ;
41- }
42- roleGroups [ item . role ] += 1 ;
43- } ) ;
44- const chartData = Object . entries ( roleGroups )
45- . map ( ( [ role , applicationCount ] ) => ( {
46- role,
47- applications : applicationCount ,
48- hits : Math . floor ( applicationCount * ( Math . random ( ) * 10 + 5 ) ) ,
49- } ) )
51+
52+ // Build rows and sort least -> most popular
53+ const rows = Array . from ( counts . entries ( ) )
54+ . map ( ( [ role , applications ] ) => ( { role, applications } ) )
5055 . sort ( ( a , b ) => a . applications - b . applications ) ;
51- return chartData ;
52- } , [ rawData , dateFilter , selectedRole ] ) ;
53- const roles = useMemo ( ( ) => {
54- const uniqueRoles = [ ...new Set ( rawData . map ( item => item . role ) ) ] ;
55- return [ 'all' , ...uniqueRoles ] ;
56- } , [ rawData ] ) ;
5756
58- const maxApplications = Math . max ( ...processedData . map ( item => item . applications ) , 10 ) ;
57+ const max = rows . length ? Math . max ( ...rows . map ( r => r . applications ) ) : 0 ;
58+ return { chartData : rows , maxApplications : max } ;
59+ } , [ rawData , startDate , endDate , selectedRole ] ) ;
5960
60- const xAxisTicks = useMemo ( ( ) => {
61- const ticks = [ 0 ] ;
62- let value = 5 ;
63- while ( value < maxApplications ) {
64- ticks . push ( value ) ;
65- value += 5 ;
66- }
67- if ( maxApplications > 0 && ticks [ ticks . length - 1 ] !== maxApplications ) {
68- ticks . push ( maxApplications ) ;
61+ const showingCount = chartData . length ;
62+ const least = showingCount ? chartData [ 0 ] : null ;
63+ const most = showingCount ? chartData [ chartData . length - 1 ] : null ;
64+
65+ const ticks = useMemo ( ( ) => {
66+ const m = maxApplications || 0 ;
67+ if ( m === 0 ) return [ 0 ] ;
68+
69+ // Choose a base step aiming for ~4 intervals, but round to friendly numbers
70+ let base = Math . ceil ( m / 4 ) ;
71+ // If base is at least 5, snap it to nearest multiple of 5 for nicer ticks
72+ if ( base >= 5 ) {
73+ base = Math . max ( 5 , Math . round ( base / 5 ) * 5 ) ;
6974 }
70- return ticks ;
75+
76+ // Build ticks from 0 up to the next multiple of base that covers m
77+ const maxTick = Math . ceil ( m / base ) * base ;
78+ const ticksOut = [ ] ;
79+ for ( let v = 0 ; v <= maxTick ; v += base ) ticksOut . push ( v ) ;
80+ return ticksOut ;
7181 } , [ maxApplications ] ) ;
7282
7383 return (
74- < div className = { darkMode ? styles . jobAnalyticsContainerDarkMode : '' } >
75- < div className = { styles . jobAnalyticsContainer } >
76- < div className = { styles . chartContainer } >
77- < h2 className = { styles . chartTitle } > Least Popular Roles</ h2 >
78- < div
79- className = { styles . chartArea }
80- style = {
81- processedData . length > 0
82- ? { '--x-grid-divisions' : String ( Math . max ( 1 , xAxisTicks . length - 1 ) ) }
83- : undefined
84- }
85- >
86- { processedData . length > 0 ? (
87- < >
88- < div className = { styles . gridLines } />
89- < div className = { styles . yAxis } >
90- { processedData . map ( item => (
91- < div key = { uuidv4 ( ) } className = { styles . yAxisLabel } >
92- { item . role }
93- </ div >
94- ) ) }
95- </ div >
96- < div className = { styles . xAxis } >
97- { xAxisTicks . map ( tick => (
98- < div
99- key = { tick }
100- className = { styles . xAxisTick }
101- style = { {
102- left : `${ ( tick / maxApplications ) * 100 } %` ,
103- transform : 'translateX(-50%)' ,
104- } }
105- >
106- { tick }
107- </ div >
108- ) ) }
109- </ div >
110- < div className = { styles . barsContainer } >
111- { processedData . map ( ( item , index ) => (
112- < div
113- key = { uuidv4 ( ) }
114- className = { styles . barRow }
115- onMouseEnter = { ( ) => setHoveredBar ( index ) }
116- onMouseLeave = { ( ) => setHoveredBar ( null ) }
117- >
118- < div
119- className = { styles . bar }
120- style = { {
121- width : `${ ( item . applications / maxApplications ) * 100 } %` ,
122- } }
123- >
124- < div className = { styles . dataLabel } > { item . applications } </ div >
125- </ div >
126- { hoveredBar === index && (
127- < div className = { styles . tooltip } >
128- < div className = { styles . tooltipTitle } >
129- < strong > { item . role } </ strong >
84+ < div className = { `${ styles . ja } ${ darkMode ? styles . dark : '' } ` } style = { { minHeight : '105vh' } } >
85+ < div className = { styles . jaMain } >
86+ { /* Left: Chart card */ }
87+ < section className = { styles . jaCard } >
88+ < h2 className = { styles . jaTitle } > Least popular roles</ h2 >
89+
90+ { invalidRange && (
91+ < div className = { styles . jaWarning } role = "alert" >
92+ Start date cannot be after end date.
93+ </ div >
94+ ) }
95+
96+ { showingCount ? (
97+ < >
98+ < div className = { styles . jaChart } >
99+ < div className = { styles . jaGrid } aria-hidden = "true" />
100+ < div className = { styles . jaBars } >
101+ { chartData . map ( row => {
102+ const pct =
103+ maxApplications > 0
104+ ? Math . max ( 2 , ( row . applications / maxApplications ) * 100 )
105+ : 0 ;
106+ return (
107+ < div className = { styles . jaRow } key = { row . role } >
108+ < div className = { styles . jaLabel } title = { row . role } >
109+ { row . role }
110+ </ div >
111+ < div className = { styles . jaTrack } >
112+ < div className = { styles . jaBar } style = { { width : `${ pct } %` } } >
113+ < span className = { styles . jaValue } > { row . applications } </ span >
130114 </ div >
131- < div > Applications: { item . applications } </ div >
132- < div > Hits: { item . hits } </ div >
133115 </ div >
134- ) }
135- </ div >
136- ) ) }
116+ </ div >
117+ ) ;
118+ } ) }
137119 </ div >
138- < div className = { styles . xAxisLabel } > Applications</ div >
139- </ >
140- ) : (
141- < div className = { styles . noData } > No data available for the selected filters</ div >
142- ) }
143- </ div >
144- { processedData . length > 0 && (
145- < div className = { styles . summaryInfo } >
146- < div >
147- < strong > Showing:</ strong > { processedData . length } role(s)
148- </ div >
149- < div >
150- < strong > Least Popular:</ strong > { processedData [ 0 ] ?. role } (
151- { processedData [ 0 ] ?. applications } applications)
152120 </ div >
153- < div >
154- < strong > Most Popular:</ strong > { processedData [ processedData . length - 1 ] ?. role } (
155- { processedData [ processedData . length - 1 ] ?. applications } applications)
121+
122+ < div className = { styles . jaXaxis } >
123+ { ticks . map ( t => (
124+ < span key = { t } > { t } </ span >
125+ ) ) }
156126 </ div >
157- </ div >
127+ < div className = { styles . jaXaxisLabel } > Applications</ div >
128+ </ >
129+ ) : (
130+ < div className = { styles . jaNoData } > No data for the selected filters.</ div >
158131 ) }
159- </ div >
160- < div className = { styles . filtersPanel } >
161- < div className = { styles . filterGroup } >
162- < div className = { styles . filterLabel } > Dates</ div >
163- < select
164- value = { dateFilter }
165- onChange = { e => setDateFilter ( e . target . value ) }
166- className = { styles . filterSelectJobAnalytics }
167- >
168- < option value = "all" > ALL</ option >
169- < option value = "weekly" > Last 7 Days</ option >
170- < option value = "monthly" > Last 30 Days</ option >
171- < option value = "yearly" > Last Year</ option >
172- </ select >
132+
133+ < div className = { styles . jaFooter } >
134+ < div >
135+ < strong > Showing:</ strong > { showingCount } role(s)
136+ </ div >
137+ < div >
138+ < strong > Least Popular:</ strong > { ' ' }
139+ { least ? `${ least . role } (${ least . applications } applications)` : '—' }
140+ </ div >
141+ < div >
142+ < strong > Most Popular:</ strong > { ' ' }
143+ { most ? `${ most . role } (${ most . applications } applications)` : '—' }
144+ </ div >
173145 </ div >
174- < div className = { styles . filterGroup } >
175- < div className = { styles . filterLabel } > Role</ div >
146+ </ section >
147+
148+ { /* Right: Filters */ }
149+ < aside className = { styles . jaFilters } >
150+ < div className = { styles . jaFilter } >
151+ < div className = { styles . jaFilterLabel } > Dates</ div >
152+ < div className = { styles . jaDateRange } >
153+ < input
154+ type = "date"
155+ value = { startDate }
156+ onChange = { e => setStartDate ( e . target . value ) }
157+ aria-label = "Start date"
158+ />
159+ < span className = { styles . jaDateDash } > –</ span >
160+ < input
161+ type = "date"
162+ value = { endDate }
163+ onChange = { e => setEndDate ( e . target . value ) }
164+ aria-label = "End date"
165+ />
166+ { ( startDate || endDate ) && (
167+ < button
168+ type = "button"
169+ className = { styles . jaClear }
170+ onClick = { ( ) => {
171+ setStartDate ( '' ) ;
172+ setEndDate ( '' ) ;
173+ } }
174+ >
175+ Clear
176+ </ button >
177+ ) }
178+ </ div >
179+ </ div >
180+
181+ < div className = { styles . jaFilter } >
182+ < div className = { styles . jaFilterLabel } > Role</ div >
176183 < select
177184 value = { selectedRole }
178185 onChange = { e => setSelectedRole ( e . target . value ) }
179- className = { styles . filterSelectJobAnalytics }
186+ aria-label = "Filter by role"
180187 >
181188 { roles . map ( role => (
182189 < option key = { role } value = { role } >
@@ -185,7 +192,7 @@ function JobAnalytics() {
185192 ) ) }
186193 </ select >
187194 </ div >
188- </ div >
195+ </ aside >
189196 </ div >
190197 </ div >
191198 ) ;
0 commit comments