1+ import { Dispatch , Fragment , useState } from "react"
2+
13import { ClockPlus } from "lucide-react"
24
3- import { Chart , Coordinate } from "components/ui/chart"
45import { ContextInfo } from "components/ui/context-info"
5- import { projectsData } from "data/projects"
66import { timeEntriesData , TimeEntry } from "data/time-entries"
77import { useAtomValue } from "lib/yaasl"
88import { cn } from "utils/cn"
99import { vstack } from "utils/styles"
1010import { timeHelpers } from "utils/time-helpers"
1111
12+ import { getTimeStats , TimeStats } from "./get-time-stats"
13+ import { TimeStatsChart } from "./time-stats-chart"
14+
1215const getEntries = < TObj extends object > ( obj : TObj ) =>
1316 Object . entries ( obj ) as [ keyof TObj | string , TObj [ keyof TObj ] ] [ ]
1417
15- const splitByWeekdays = ( timeEntries : Record < string , TimeEntry [ ] > ) => {
16- const byWeekday : Record < number , Record < string , TimeEntry [ ] > > = { }
18+ const splitEntries = (
19+ timeEntries : Record < string , TimeEntry [ ] > ,
20+ getKey : ( date : string ) => number
21+ ) => {
22+ const result : Record < number , Record < string , TimeEntry [ ] > > = { }
23+
1724 getEntries ( timeEntries ) . forEach ( ( [ date , entries ] ) => {
18- const day = new Date ( date ) . getDay ( )
19- if ( ! byWeekday [ day ] ) byWeekday [ day ] = { }
20- byWeekday [ day ] [ date ] = entries
25+ const key = getKey ( date )
26+ if ( ! result [ key ] ) result [ key ] = { }
27+ result [ key ] [ date ] = entries
2128 } )
22- return byWeekday
29+
30+ return result
2331}
2432
25- const average = ( numbers : number [ ] ) => {
26- const sum = numbers . reduce ( ( sum , value ) => sum + value , 0 )
27- return sum / numbers . length
33+ type TimeStatsByYear = Record < number , TimeStats >
34+
35+ const getYearStats = ( timeEntries : Record < string , TimeEntry [ ] > ) => {
36+ const byYear = splitEntries ( timeEntries , date => new Date ( date ) . getFullYear ( ) )
37+ const allStats : TimeStatsByYear = { }
38+
39+ getEntries ( byYear ) . forEach ( ( [ year , entriesByDate ] ) => {
40+ const stats = getTimeStats ( entriesByDate )
41+ if ( ! stats ) return
42+ allStats [ Number ( year ) ] = stats
43+ } )
44+
45+ return allStats
2846}
2947
30- interface TimeStats {
31- start : number
32- end : number
33- total : number
48+ type TimeStatsByMonth = Record < number , TimeStats >
49+
50+ const getMonthStats = ( timeEntries : Record < string , TimeEntry [ ] > ) => {
51+ const byMonth = splitEntries ( timeEntries , date => new Date ( date ) . getMonth ( ) )
52+ const allStats : TimeStatsByMonth = { }
53+
54+ getEntries ( byMonth ) . forEach ( ( [ month , entriesByDate ] ) => {
55+ const stats = getTimeStats ( entriesByDate )
56+ if ( ! stats ) return
57+ allStats [ Number ( month ) ] = stats
58+ } )
59+
60+ return allStats
3461}
35- type TimeStatsByDay = Record < number , TimeStats >
3662
37- const getProject = ( projectId ?: string ) =>
38- projectsData . get ( ) . find ( project => project . id === projectId )
63+ type TimeStatsByDay = Record < number , TimeStats >
3964
4065const getDayStats = ( timeEntries : Record < string , TimeEntry [ ] > ) => {
41- const byWeekday = splitByWeekdays ( timeEntries )
66+ const byWeekday = splitEntries ( timeEntries , date => new Date ( date ) . getDay ( ) )
4267 const allStats : TimeStatsByDay = { }
4368
4469 getEntries ( byWeekday ) . forEach ( ( [ weekday , entriesByDate ] ) => {
45- const total : number [ ] = [ ]
46- const start : number [ ] = [ ]
47- const end : number [ ] = [ ]
48-
49- Object . values ( entriesByDate ) . forEach ( entries => {
50- const times = entries . flatMap ( ( { start, end, projectId } ) =>
51- getProject ( projectId ) ?. isPrivate
52- ? [ ]
53- : {
54- start : timeHelpers . toMinutes ( start ) ,
55- end : timeHelpers . toMinutes ( end ) ,
56- }
57- )
58- if ( times . length === 0 ) return
59-
60- const dateStart = Math . min ( ...times . map ( ( { start } ) => start ) )
61- const dateEnd = Math . max ( ...times . map ( ( { end } ) => end ) )
62-
63- const totalTime = times . reduce ( ( total , { start, end } ) => {
64- const duration = end - start
65- return total + duration
66- } , 0 )
67-
68- start . push ( dateStart )
69- end . push ( dateEnd )
70- total . push ( totalTime )
71- } )
72-
73- if ( total . length === 0 ) return
74-
75- allStats [ Number ( weekday ) ] = {
76- start : average ( start ) ,
77- end : average ( end ) ,
78- total : average ( total ) ,
79- }
70+ const stats = getTimeStats ( entriesByDate )
71+ if ( ! stats ) return
72+ allStats [ Number ( weekday ) ] = stats
8073 } )
8174
8275 return allStats
8376}
8477
85- const DayChart = ( {
86- caption,
87- dayStats,
88- type,
89- printValue,
78+ const weekdayTick = ( value : number ) =>
79+ [ "Mo" , "Tu" , "We" , "Th" , "Fr" , "Sa" , "Su" ] [ value ] ?? "??"
80+
81+ const transformWeekday = ( x : number ) =>
82+ // start week with monday
83+ ( x + 6 ) % 7
84+
85+ const monthTick = ( value : number ) =>
86+ [
87+ "Jan" ,
88+ "Feb" ,
89+ "Mar" ,
90+ "Apr" ,
91+ "May" ,
92+ "Jun" ,
93+ "Jul" ,
94+ "Aug" ,
95+ "Sep" ,
96+ "Oct" ,
97+ "Nov" ,
98+ "Dec" ,
99+ ] [ value ] ?? "??"
100+
101+ type Mode = "weekday" | "month" | "year"
102+
103+ const StatsModeHeader = ( {
104+ mode,
105+ onChange,
90106} : {
91- caption : string
92- dayStats : TimeStatsByDay
93- type : keyof TimeStats
94- printValue : ( coord : Coordinate ) => string
95- } ) => {
96- const points = getEntries ( dayStats )
97- . flatMap ( ( [ day , stats ] ) =>
98- ! stats [ type ]
99- ? [ ]
100- : {
101- day : ( Number ( day ) + 6 ) % 7 , // start week with monday
102- value : stats [ type ] ,
103- }
104- )
105- . sort ( ( a , b ) => a . day - b . day )
106- . map ( ( { day, value } , index ) => ( { x : index , y : value , day } ) )
107-
108- const { min, max } = Chart . utils . getExtremes ( points )
109-
110- const minY = min . y + ( 15 - ( min . y % 15 ) ) - 30
111- const maxY = max . y - ( max . y % 15 ) + 30
112-
113- const ticks = ( ( ) => {
114- const ticks : Record < number , string > = { }
115- points . forEach (
116- ( { x, day } ) =>
117- ( ticks [ x ] = [ "Mo" , "Tu" , "We" , "Th" , "Fr" , "Sa" , "Su" ] [ day ] ?? "??" )
118- )
119- return ticks
120- } ) ( )
121-
122- return (
123- < Chart . Root maxX = { max . x } minY = { minY } maxY = { maxY } >
124- < Chart . Caption > { caption } </ Chart . Caption >
125- < Chart . Line points = { points } />
126- < Chart . Dots points = { points } printValue = { printValue } />
127-
128- < Chart . XAxis color = "gentle" position = { minY } ticks = { ticks } />
129- < Chart . Grid gapY = { 15 } />
130- </ Chart . Root >
131- )
132- }
107+ mode : Mode
108+ onChange : Dispatch < Mode >
109+ } ) => (
110+ < h2 className = "mb-2 text-xl" >
111+ < span className = "text-text-muted" > Stats by </ span >
112+ { ( [ "weekday" , "month" , "year" ] as const ) . map ( ( value , index ) => (
113+ < Fragment key = { value } >
114+ { index !== 0 && < span className = "font-bold text-text-muted" > | </ span > }
115+ < button
116+ key = { value }
117+ onClick = { ( ) => onChange ( value ) }
118+ className = { cn (
119+ "cursor-pointer font-bold text-text-gentle uppercase underline-offset-4 hover:text-text hover:underline" ,
120+ value === mode && "text-text-priority"
121+ ) }
122+ >
123+ { value }
124+ </ button >
125+ </ Fragment >
126+ ) ) }
127+ </ h2 >
128+ )
133129
134130export const StatsRoute = ( ) => {
135131 const timeEntries = useAtomValue ( timeEntriesData )
136- const dayStats = getDayStats ( timeEntries )
137-
138- if ( Object . values ( dayStats ) . length < 2 ) {
132+ const [ mode , setMode ] = useState < Mode > ( "weekday" )
133+
134+ const data = {
135+ weekday : ( ) => getDayStats ( timeEntries ) ,
136+ month : ( ) => getMonthStats ( timeEntries ) ,
137+ year : ( ) => getYearStats ( timeEntries ) ,
138+ } [ mode ] ( )
139+
140+ const tick = {
141+ weekday : weekdayTick ,
142+ month : monthTick ,
143+ year : ( value : number ) => String ( value ) ,
144+ } [ mode ]
145+
146+ const transform = {
147+ weekday : transformWeekday ,
148+ month : ( x : number ) => x ,
149+ year : ( x : number ) => x ,
150+ } [ mode ]
151+
152+ if ( Object . values ( data ) . length < 2 ) {
139153 return (
140154 < div className = "grid h-full place-items-center" >
141155 < ContextInfo
@@ -146,27 +160,35 @@ export const StatsRoute = () => {
146160 </ div >
147161 )
148162 }
163+
149164 return (
150165 < div className = { cn ( vstack ( { } ) , "h-full px-10 pt-6" ) } >
151- < h2 className = "mb-2 text-xl" > Stats by week day</ h2 >
152- < div className = "grid auto-rows-[10rem] grid-cols-[repeat(auto-fit,minmax(20rem,1fr))] gap-10 *:h-full" >
153- < DayChart
166+ < StatsModeHeader mode = { mode } onChange = { setMode } />
167+
168+ < div className = "grid auto-rows-[10rem] grid-cols-[repeat(auto-fit,minmax(30rem,1fr))] gap-10 *:h-full" >
169+ < TimeStatsChart
154170 caption = "Start time"
155- dayStats = { dayStats }
171+ timeStats = { data }
156172 type = "start"
157- printValue = { ( { y } ) => timeHelpers . fromMinutes ( y ) }
173+ dotLabel = { ( { y } ) => timeHelpers . fromMinutes ( y ) }
174+ tickLabel = { tick }
175+ transformX = { transform }
158176 />
159- < DayChart
177+ < TimeStatsChart
160178 caption = "End time"
161- dayStats = { dayStats }
179+ timeStats = { data }
162180 type = "end"
163- printValue = { ( { y } ) => timeHelpers . fromMinutes ( y ) }
181+ dotLabel = { ( { y } ) => timeHelpers . fromMinutes ( y ) }
182+ tickLabel = { tick }
183+ transformX = { transform }
164184 />
165- < DayChart
185+ < TimeStatsChart
166186 caption = "Work time"
167- dayStats = { dayStats }
187+ timeStats = { data }
168188 type = "total"
169- printValue = { ( { y } ) => `${ ( y / 60 ) . toFixed ( 1 ) } h` }
189+ dotLabel = { ( { y } ) => `${ ( y / 60 ) . toFixed ( 1 ) } h` }
190+ tickLabel = { tick }
191+ transformX = { transform }
170192 />
171193 </ div >
172194
0 commit comments