Skip to content

Commit dadec35

Browse files
authored
Merge pull request #103 from sheepzh/dashboard
Dashboard
2 parents 9124402 + e1f6078 commit dadec35

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

41 files changed

+1891
-271
lines changed

.vscode/settings.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,11 @@
1818
"cSpell.words": [
1919
"Echart",
2020
"cond",
21+
"countup",
2122
"daterange",
2223
"dkdhhcbjijekmneelocdllcldcpmekmm",
23-
"fepjgblalcnepokjblgbgmapmlkgfahc",
2424
"emsp",
25-
"ensp"
25+
"ensp",
26+
"fepjgblalcnepokjblgbgmapmlkgfahc"
2627
]
2728
}

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,10 +48,11 @@
4848
"@element-plus/icons-vue": "^1.1.4",
4949
"axios": "^0.27.2",
5050
"clipboardy": "^3.0.0",
51+
"countup.js": "^2.2.0",
5152
"echarts": "^5.3.2",
5253
"element-plus": "1.2.0-beta.6",
5354
"psl": "^1.8.0",
5455
"vue": "^3.2.33",
5556
"vue-router": "^4.0.14"
5657
}
57-
}
58+
}
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
/**
2+
* Copyright (c) 2022 Hengyang Zhang
3+
*
4+
* This software is released under the MIT License.
5+
* https://opensource.org/licenses/MIT
6+
*/
7+
8+
import { defineComponent, h, onMounted, ref, watch } from "vue"
9+
import { CountUp } from "countup.js"
10+
import type { Ref } from "vue"
11+
12+
const _default = defineComponent({
13+
name: "NumberGrow",
14+
props: {
15+
value: Number,
16+
duration: Number,
17+
fontSize: Number,
18+
},
19+
emits: ['stop'],
20+
setup(props) {
21+
const elRef: Ref<HTMLElement> = ref()
22+
const countUp: Ref<CountUp> = ref()
23+
const style: Partial<CSSStyleDeclaration> = {
24+
textDecoration: 'underline'
25+
}
26+
props.fontSize && (style.fontSize = `${props.fontSize}px`)
27+
28+
onMounted(() => {
29+
countUp.value = new CountUp(elRef.value, props.value, {
30+
startVal: 0,
31+
duration: props.duration || 1.5,
32+
separator: ',',
33+
})
34+
if (countUp.value.error) {
35+
console.log(countUp.value.error)
36+
}
37+
countUp.value.start()
38+
})
39+
40+
watch(() => props.value, newVal => countUp.value?.update(newVal))
41+
42+
return () => h('a', { style, ref: elRef })
43+
}
44+
})
45+
46+
export default _default
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
/**
2+
* Copyright (c) 2022 Hengyang Zhang
3+
*
4+
* This software is released under the MIT License.
5+
* https://opensource.org/licenses/MIT
6+
*/
7+
8+
import { ElCard, ElCol } from "element-plus"
9+
import { defineComponent, h } from "vue"
10+
11+
const _default = defineComponent({
12+
name: "DashboardCard",
13+
props: {
14+
span: {
15+
type: Number,
16+
required: true
17+
}
18+
},
19+
setup(props, ctx) {
20+
return () => h(ElCol, {
21+
span: props.span
22+
}, () => h(ElCard, {
23+
style: { height: "100%" }
24+
}, () => h(ctx.slots.default)))
25+
}
26+
})
27+
28+
export default _default
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
/**
2+
* Copyright (c) 2022 Hengyang Zhang
3+
*
4+
* This software is released under the MIT License.
5+
* https://opensource.org/licenses/MIT
6+
*/
7+
8+
import { TitleComponentOption } from 'echarts/components'
9+
10+
export const BASE_TITLE_OPTION: TitleComponentOption = {
11+
textStyle: {
12+
fontSize: '14px'
13+
},
14+
show: true,
15+
left: '1%',
16+
top: '0%',
17+
}
Lines changed: 258 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,258 @@
1+
/**
2+
* Copyright (c) 2022 Hengyang Zhang
3+
*
4+
* This software is released under the MIT License.
5+
* https://opensource.org/licenses/MIT
6+
*/
7+
8+
import type { ECharts } from "echarts/core"
9+
import { init, use, ComposeOption } from "echarts/core"
10+
import { HeatmapChart, HeatmapSeriesOption } from "echarts/charts"
11+
import {
12+
TitleComponent, TitleComponentOption,
13+
TooltipComponent, TooltipComponentOption,
14+
GridComponent, GridComponentOption,
15+
VisualMapComponent, VisualMapComponentOption,
16+
} from "echarts/components"
17+
import { CanvasRenderer } from "echarts/renderers"
18+
19+
// Register echarts
20+
use([
21+
CanvasRenderer,
22+
HeatmapChart,
23+
TooltipComponent,
24+
GridComponent,
25+
VisualMapComponent,
26+
TitleComponent,
27+
])
28+
29+
import { t } from "@app/locale"
30+
import { MILL_PER_MINUTE } from "@entity/dto/period-info"
31+
import timerService, { TimerQueryParam } from "@service/timer-service"
32+
import { locale } from "@util/i18n"
33+
import { formatTime, getWeeksAgo, MILL_PER_DAY } from "@util/time"
34+
import { ElLoading } from "element-plus"
35+
import { defineComponent, h, onMounted, ref, Ref } from "vue"
36+
import { groupBy, rotate } from "@util/array"
37+
import { BASE_TITLE_OPTION } from "../common"
38+
39+
const WEEK_NUM = 53
40+
41+
const CONTAINER_ID = "__timer_dashboard_heatmap"
42+
43+
type _Value = [
44+
// X
45+
number,
46+
// Y
47+
number,
48+
// Value
49+
number,
50+
// date yyyyMMdd
51+
string,
52+
]
53+
54+
type EcOption = ComposeOption<
55+
| HeatmapSeriesOption
56+
| TitleComponentOption
57+
| TooltipComponentOption
58+
| GridComponentOption
59+
| VisualMapComponentOption
60+
>
61+
62+
function formatTooltip(minutes: number, date: string): string {
63+
const hour = Math.floor(minutes / 60)
64+
const minute = minutes % 60
65+
const year = date.substr(0, 4)
66+
const month = date.substr(4, 2)
67+
const day = date.substr(6, 2)
68+
const placeholders = {
69+
hour, minute, year, month, day
70+
}
71+
72+
return t(
73+
msg => hour
74+
// With hour
75+
? msg.dashboard.heatMap.tooltip1
76+
// Without hour
77+
: msg.dashboard.heatMap.tooltip0,
78+
placeholders
79+
)
80+
}
81+
82+
function getGridColors() {
83+
return ['a', 'b', 'c', 'd'].map(ch => getComputedStyle(document.body).getPropertyValue(`--timer-dashboard-heatmap-color-${ch}`))
84+
}
85+
86+
function getXAxisLabelMap(data: _Value[]): { [x: string]: string } {
87+
const allMonthLabel = t(msg => msg.calendar.months).split('|')
88+
const result = {}
89+
// {[ x:string ]: Set<string> }
90+
const xAndMonthMap = groupBy(data, e => e[0], grouped => new Set(grouped.map(a => a[3].substr(4, 2))))
91+
let lastMonth = undefined
92+
Object.entries(xAndMonthMap).forEach(([x, monthSet]) => {
93+
if (monthSet.size != 1) {
94+
return
95+
}
96+
const currentMonth = Array.from(monthSet)[0]
97+
if (currentMonth === lastMonth) {
98+
return
99+
}
100+
lastMonth = currentMonth
101+
const monthNum = parseInt(currentMonth)
102+
const label = allMonthLabel[monthNum - 1]
103+
result[x] = label
104+
})
105+
return result
106+
}
107+
108+
function optionOf(data: _Value[], days: string[]): EcOption {
109+
const totalMinutes = data.map(d => d[2] || 0).reduce((a, b) => a + b, 0)
110+
const totalHours = Math.floor(totalMinutes / 60)
111+
const xAxisLabelMap = getXAxisLabelMap(data)
112+
return {
113+
title: {
114+
...BASE_TITLE_OPTION,
115+
text: t(msg => totalHours
116+
? msg.dashboard.heatMap.title0
117+
: msg.dashboard.heatMap.title1,
118+
{ hour: totalHours }
119+
)
120+
},
121+
tooltip: {
122+
position: 'top',
123+
formatter: (params: any) => {
124+
const { data } = params
125+
const { value } = data
126+
const [_1, _2, minutes, date] = value
127+
return minutes ? formatTooltip(minutes as number, date) : undefined
128+
}
129+
},
130+
grid: { height: '70%', width: '82%', left: '8%', top: '18%', },
131+
xAxis: {
132+
type: 'category',
133+
axisLine: { show: false },
134+
axisTick: { show: false, alignWithLabel: true },
135+
axisLabel: {
136+
formatter: (x: string) => xAxisLabelMap[x] || '',
137+
interval: 0,
138+
margin: 14,
139+
},
140+
},
141+
yAxis: {
142+
type: 'category',
143+
data: days,
144+
axisLabel: { padding: /* T R B L */[0, 12, 0, 0] },
145+
axisLine: { show: false },
146+
axisTick: { show: false, alignWithLabel: true }
147+
},
148+
visualMap: [{
149+
min: 1,
150+
max: Math.max(...data.map(a => a[2])),
151+
inRange: { color: getGridColors() },
152+
realtime: true,
153+
calculable: true,
154+
orient: 'vertical',
155+
right: '2%',
156+
top: 'center',
157+
dimension: 2
158+
}],
159+
series: [{
160+
name: 'Daily Focus',
161+
type: 'heatmap',
162+
data: data.map(d => {
163+
let item = { value: d, itemStyle: undefined, label: undefined, emphasis: undefined, tooltip: undefined, silent: false }
164+
const minutes = d[2]
165+
const date = d[3]
166+
if (minutes) {
167+
} else {
168+
item.itemStyle = {
169+
color: '#fff',
170+
}
171+
item.emphasis = {
172+
disabled: true
173+
}
174+
item.silent = true
175+
}
176+
return item
177+
}),
178+
progressive: 5,
179+
progressiveThreshold: 10,
180+
}]
181+
}
182+
}
183+
184+
class ChartWrapper {
185+
instance: ECharts
186+
allDates: string[]
187+
188+
constructor(startTime: Date, endTime: Date) {
189+
let currentTs = startTime.getTime()
190+
let maxTs = endTime.getTime()
191+
this.allDates = []
192+
for (; currentTs < maxTs; currentTs += MILL_PER_DAY) {
193+
this.allDates.push(formatTime(currentTs, '{y}{m}{d}'))
194+
}
195+
}
196+
197+
init(container: HTMLDivElement) {
198+
this.instance = init(container)
199+
}
200+
201+
render(value: { [date: string]: number }, days: string[], loading: { close: () => void }) {
202+
const data: _Value[] = []
203+
this.allDates.forEach((date, index) => {
204+
const dailyMills = value[date] || 0
205+
const dailyMinutes = Math.floor(dailyMills / MILL_PER_MINUTE)
206+
const colIndex = parseInt((index / 7).toString())
207+
const weekDay = index % 7
208+
const x = colIndex, y = 7 - (1 + weekDay)
209+
data.push([x, y, dailyMinutes, date])
210+
})
211+
const option = optionOf(data, days)
212+
this.instance.setOption(option)
213+
loading.close()
214+
}
215+
}
216+
217+
const _default = defineComponent({
218+
name: "CalendarHeatMap",
219+
setup() {
220+
const isChinese = locale === "zh_CN"
221+
const now = new Date()
222+
const startTime: Date = getWeeksAgo(now, isChinese, WEEK_NUM)
223+
224+
const chart: Ref = ref()
225+
const chartWrapper: ChartWrapper = new ChartWrapper(startTime, now)
226+
227+
onMounted(async () => {
228+
// 1. loading
229+
const loading = ElLoading.service({
230+
target: `#${CONTAINER_ID}`,
231+
})
232+
// 2. init chart
233+
chartWrapper.init(chart.value)
234+
// 3. query data
235+
const query: TimerQueryParam = { date: [startTime, now], sort: "date" }
236+
const items = await timerService.select(query)
237+
const result = {}
238+
items.forEach(({ date, focus }) => result[date] = (result[date] || 0) + focus)
239+
// 4. set weekdays
240+
// Sunday to Monday
241+
const weekDays = (t(msg => msg.calendar.weekDays)?.split?.('|') || []).reverse()
242+
if (!isChinese) {
243+
// Let Sunday last
244+
// Saturday to Sunday
245+
rotate(weekDays, 1)
246+
}
247+
// 5. render
248+
chartWrapper.render(result, weekDays, loading)
249+
})
250+
return () => h('div', {
251+
id: CONTAINER_ID,
252+
class: 'chart-container',
253+
ref: chart,
254+
})
255+
}
256+
})
257+
258+
export default _default

0 commit comments

Comments
 (0)