Skip to content

Commit b5d0e82

Browse files
committed
feat(StatsRoute): Add month and year stats
1 parent 79edf9a commit b5d0e82

4 files changed

Lines changed: 245 additions & 116 deletions

File tree

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import { projectsData } from "data/projects"
2+
import { TimeEntry } from "data/time-entries"
3+
import { timeHelpers } from "utils/time-helpers"
4+
5+
const average = (numbers: number[]) => {
6+
const sum = numbers.reduce((sum, value) => sum + value, 0)
7+
return sum / numbers.length
8+
}
9+
10+
const getProject = (projectId?: string) =>
11+
projectsData.get().find(project => project.id === projectId)
12+
13+
export interface TimeStats {
14+
start: number
15+
end: number
16+
total: number
17+
}
18+
19+
export const getTimeStats = (
20+
entries: Record<string, TimeEntry[]>
21+
): TimeStats | null => {
22+
const total: number[] = []
23+
const start: number[] = []
24+
const end: number[] = []
25+
26+
Object.values(entries).forEach(entries => {
27+
const times = entries.flatMap(({ start, end, projectId }) =>
28+
getProject(projectId)?.isPrivate
29+
? []
30+
: {
31+
start: timeHelpers.toMinutes(start),
32+
end: timeHelpers.toMinutes(end),
33+
}
34+
)
35+
if (times.length === 0) return
36+
37+
const dateStart = Math.min(...times.map(({ start }) => start))
38+
const dateEnd = Math.max(...times.map(({ end }) => end))
39+
40+
const totalTime = times.reduce((total, { start, end }) => {
41+
const duration = end - start
42+
return total + duration
43+
}, 0)
44+
45+
start.push(dateStart)
46+
end.push(dateEnd)
47+
total.push(totalTime)
48+
})
49+
50+
if (total.length === 0) return null
51+
52+
return {
53+
start: average(start),
54+
end: average(end),
55+
total: average(total),
56+
}
57+
}

src/app/routes/stats/stats-route.tsx

Lines changed: 137 additions & 115 deletions
Original file line numberDiff line numberDiff line change
@@ -1,141 +1,155 @@
1+
import { Dispatch, Fragment, useState } from "react"
2+
13
import { ClockPlus } from "lucide-react"
24

3-
import { Chart, Coordinate } from "components/ui/chart"
45
import { ContextInfo } from "components/ui/context-info"
5-
import { projectsData } from "data/projects"
66
import { timeEntriesData, TimeEntry } from "data/time-entries"
77
import { useAtomValue } from "lib/yaasl"
88
import { cn } from "utils/cn"
99
import { vstack } from "utils/styles"
1010
import { timeHelpers } from "utils/time-helpers"
1111

12+
import { getTimeStats, TimeStats } from "./get-time-stats"
13+
import { TimeStatsChart } from "./time-stats-chart"
14+
1215
const 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

4065
const 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

134130
export 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

Comments
 (0)