diff --git a/.vscode/settings.json b/.vscode/settings.json index 100da58af..9bc3be71d 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -18,10 +18,11 @@ "cSpell.words": [ "Echart", "cond", + "countup", "daterange", "dkdhhcbjijekmneelocdllcldcpmekmm", - "fepjgblalcnepokjblgbgmapmlkgfahc", "emsp", - "ensp" + "ensp", + "fepjgblalcnepokjblgbgmapmlkgfahc" ] } \ No newline at end of file diff --git a/package.json b/package.json index 7a63ae6eb..091d52740 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "timer", - "version": "0.9.6", + "version": "1.0.0", "description": "Web timer", "homepage": "https://github.com/sheepzh/timer", "scripts": { @@ -48,6 +48,7 @@ "@element-plus/icons-vue": "^1.1.4", "axios": "^0.27.2", "clipboardy": "^3.0.0", + "countup.js": "^2.2.0", "echarts": "^5.3.2", "element-plus": "1.2.0-beta.6", "psl": "^1.8.0", diff --git a/src/app/components/common/number-grow.ts b/src/app/components/common/number-grow.ts new file mode 100644 index 000000000..2a90e95f4 --- /dev/null +++ b/src/app/components/common/number-grow.ts @@ -0,0 +1,46 @@ +/** + * Copyright (c) 2022 Hengyang Zhang + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +import { defineComponent, h, onMounted, ref, watch } from "vue" +import { CountUp } from "countup.js" +import type { Ref } from "vue" + +const _default = defineComponent({ + name: "NumberGrow", + props: { + value: Number, + duration: Number, + fontSize: Number, + }, + emits: ['stop'], + setup(props) { + const elRef: Ref = ref() + const countUp: Ref = ref() + const style: Partial = { + textDecoration: 'underline' + } + props.fontSize && (style.fontSize = `${props.fontSize}px`) + + onMounted(() => { + countUp.value = new CountUp(elRef.value, props.value, { + startVal: 0, + duration: props.duration || 1.5, + separator: ',', + }) + if (countUp.value.error) { + console.log(countUp.value.error) + } + countUp.value.start() + }) + + watch(() => props.value, newVal => countUp.value?.update(newVal)) + + return () => h('a', { style, ref: elRef }) + } +}) + +export default _default \ No newline at end of file diff --git a/src/app/components/dashboard/card.ts b/src/app/components/dashboard/card.ts new file mode 100644 index 000000000..53ab3dc71 --- /dev/null +++ b/src/app/components/dashboard/card.ts @@ -0,0 +1,28 @@ +/** + * Copyright (c) 2022 Hengyang Zhang + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +import { ElCard, ElCol } from "element-plus" +import { defineComponent, h } from "vue" + +const _default = defineComponent({ + name: "DashboardCard", + props: { + span: { + type: Number, + required: true + } + }, + setup(props, ctx) { + return () => h(ElCol, { + span: props.span + }, () => h(ElCard, { + style: { height: "100%" } + }, () => h(ctx.slots.default))) + } +}) + +export default _default \ No newline at end of file diff --git a/src/app/components/dashboard/common.ts b/src/app/components/dashboard/common.ts new file mode 100644 index 000000000..466b26b0c --- /dev/null +++ b/src/app/components/dashboard/common.ts @@ -0,0 +1,17 @@ +/** + * Copyright (c) 2022 Hengyang Zhang + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +import { TitleComponentOption } from 'echarts/components' + +export const BASE_TITLE_OPTION: TitleComponentOption = { + textStyle: { + fontSize: '14px' + }, + show: true, + left: '1%', + top: '0%', +} \ No newline at end of file diff --git a/src/app/components/dashboard/components/calendar-heat-map.ts b/src/app/components/dashboard/components/calendar-heat-map.ts new file mode 100644 index 000000000..51a2a46ff --- /dev/null +++ b/src/app/components/dashboard/components/calendar-heat-map.ts @@ -0,0 +1,258 @@ +/** + * Copyright (c) 2022 Hengyang Zhang + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +import type { ECharts } from "echarts/core" +import { init, use, ComposeOption } from "echarts/core" +import { HeatmapChart, HeatmapSeriesOption } from "echarts/charts" +import { + TitleComponent, TitleComponentOption, + TooltipComponent, TooltipComponentOption, + GridComponent, GridComponentOption, + VisualMapComponent, VisualMapComponentOption, +} from "echarts/components" +import { CanvasRenderer } from "echarts/renderers" + +// Register echarts +use([ + CanvasRenderer, + HeatmapChart, + TooltipComponent, + GridComponent, + VisualMapComponent, + TitleComponent, +]) + +import { t } from "@app/locale" +import { MILL_PER_MINUTE } from "@entity/dto/period-info" +import timerService, { TimerQueryParam } from "@service/timer-service" +import { locale } from "@util/i18n" +import { formatTime, getWeeksAgo, MILL_PER_DAY } from "@util/time" +import { ElLoading } from "element-plus" +import { defineComponent, h, onMounted, ref, Ref } from "vue" +import { groupBy, rotate } from "@util/array" +import { BASE_TITLE_OPTION } from "../common" + +const WEEK_NUM = 53 + +const CONTAINER_ID = "__timer_dashboard_heatmap" + +type _Value = [ + // X + number, + // Y + number, + // Value + number, + // date yyyyMMdd + string, +] + +type EcOption = ComposeOption< + | HeatmapSeriesOption + | TitleComponentOption + | TooltipComponentOption + | GridComponentOption + | VisualMapComponentOption +> + +function formatTooltip(minutes: number, date: string): string { + const hour = Math.floor(minutes / 60) + const minute = minutes % 60 + const year = date.substr(0, 4) + const month = date.substr(4, 2) + const day = date.substr(6, 2) + const placeholders = { + hour, minute, year, month, day + } + + return t( + msg => hour + // With hour + ? msg.dashboard.heatMap.tooltip1 + // Without hour + : msg.dashboard.heatMap.tooltip0, + placeholders + ) +} + +function getGridColors() { + return ['a', 'b', 'c', 'd'].map(ch => getComputedStyle(document.body).getPropertyValue(`--timer-dashboard-heatmap-color-${ch}`)) +} + +function getXAxisLabelMap(data: _Value[]): { [x: string]: string } { + const allMonthLabel = t(msg => msg.calendar.months).split('|') + const result = {} + // {[ x:string ]: Set } + const xAndMonthMap = groupBy(data, e => e[0], grouped => new Set(grouped.map(a => a[3].substr(4, 2)))) + let lastMonth = undefined + Object.entries(xAndMonthMap).forEach(([x, monthSet]) => { + if (monthSet.size != 1) { + return + } + const currentMonth = Array.from(monthSet)[0] + if (currentMonth === lastMonth) { + return + } + lastMonth = currentMonth + const monthNum = parseInt(currentMonth) + const label = allMonthLabel[monthNum - 1] + result[x] = label + }) + return result +} + +function optionOf(data: _Value[], days: string[]): EcOption { + const totalMinutes = data.map(d => d[2] || 0).reduce((a, b) => a + b, 0) + const totalHours = Math.floor(totalMinutes / 60) + const xAxisLabelMap = getXAxisLabelMap(data) + return { + title: { + ...BASE_TITLE_OPTION, + text: t(msg => totalHours + ? msg.dashboard.heatMap.title0 + : msg.dashboard.heatMap.title1, + { hour: totalHours } + ) + }, + tooltip: { + position: 'top', + formatter: (params: any) => { + const { data } = params + const { value } = data + const [_1, _2, minutes, date] = value + return minutes ? formatTooltip(minutes as number, date) : undefined + } + }, + grid: { height: '70%', width: '82%', left: '8%', top: '18%', }, + xAxis: { + type: 'category', + axisLine: { show: false }, + axisTick: { show: false, alignWithLabel: true }, + axisLabel: { + formatter: (x: string) => xAxisLabelMap[x] || '', + interval: 0, + margin: 14, + }, + }, + yAxis: { + type: 'category', + data: days, + axisLabel: { padding: /* T R B L */[0, 12, 0, 0] }, + axisLine: { show: false }, + axisTick: { show: false, alignWithLabel: true } + }, + visualMap: [{ + min: 1, + max: Math.max(...data.map(a => a[2])), + inRange: { color: getGridColors() }, + realtime: true, + calculable: true, + orient: 'vertical', + right: '2%', + top: 'center', + dimension: 2 + }], + series: [{ + name: 'Daily Focus', + type: 'heatmap', + data: data.map(d => { + let item = { value: d, itemStyle: undefined, label: undefined, emphasis: undefined, tooltip: undefined, silent: false } + const minutes = d[2] + const date = d[3] + if (minutes) { + } else { + item.itemStyle = { + color: '#fff', + } + item.emphasis = { + disabled: true + } + item.silent = true + } + return item + }), + progressive: 5, + progressiveThreshold: 10, + }] + } +} + +class ChartWrapper { + instance: ECharts + allDates: string[] + + constructor(startTime: Date, endTime: Date) { + let currentTs = startTime.getTime() + let maxTs = endTime.getTime() + this.allDates = [] + for (; currentTs < maxTs; currentTs += MILL_PER_DAY) { + this.allDates.push(formatTime(currentTs, '{y}{m}{d}')) + } + } + + init(container: HTMLDivElement) { + this.instance = init(container) + } + + render(value: { [date: string]: number }, days: string[], loading: { close: () => void }) { + const data: _Value[] = [] + this.allDates.forEach((date, index) => { + const dailyMills = value[date] || 0 + const dailyMinutes = Math.floor(dailyMills / MILL_PER_MINUTE) + const colIndex = parseInt((index / 7).toString()) + const weekDay = index % 7 + const x = colIndex, y = 7 - (1 + weekDay) + data.push([x, y, dailyMinutes, date]) + }) + const option = optionOf(data, days) + this.instance.setOption(option) + loading.close() + } +} + +const _default = defineComponent({ + name: "CalendarHeatMap", + setup() { + const isChinese = locale === "zh_CN" + const now = new Date() + const startTime: Date = getWeeksAgo(now, isChinese, WEEK_NUM) + + const chart: Ref = ref() + const chartWrapper: ChartWrapper = new ChartWrapper(startTime, now) + + onMounted(async () => { + // 1. loading + const loading = ElLoading.service({ + target: `#${CONTAINER_ID}`, + }) + // 2. init chart + chartWrapper.init(chart.value) + // 3. query data + const query: TimerQueryParam = { date: [startTime, now], sort: "date" } + const items = await timerService.select(query) + const result = {} + items.forEach(({ date, focus }) => result[date] = (result[date] || 0) + focus) + // 4. set weekdays + // Sunday to Monday + const weekDays = (t(msg => msg.calendar.weekDays)?.split?.('|') || []).reverse() + if (!isChinese) { + // Let Sunday last + // Saturday to Sunday + rotate(weekDays, 1) + } + // 5. render + chartWrapper.render(result, weekDays, loading) + }) + return () => h('div', { + id: CONTAINER_ID, + class: 'chart-container', + ref: chart, + }) + } +}) + +export default _default \ No newline at end of file diff --git a/src/app/components/dashboard/components/indicator/header-icon.ts b/src/app/components/dashboard/components/indicator/header-icon.ts new file mode 100644 index 000000000..10ff81caa --- /dev/null +++ b/src/app/components/dashboard/components/indicator/header-icon.ts @@ -0,0 +1,21 @@ +/** + * Copyright (c) 2022 Hengyang Zhang + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +import { Sunrise } from "@element-plus/icons-vue" +import { ElIcon } from "element-plus" +import { defineComponent, h } from "vue" + +const _default = defineComponent({ + name: "IndicatorHeaderIcon", + setup() { + return () => h('div', { + class: 'indicator-icon-header' + }, h(ElIcon, {}, () => h(Sunrise))) + } +}) + +export default _default \ No newline at end of file diff --git a/src/app/components/dashboard/components/indicator/index.ts b/src/app/components/dashboard/components/indicator/index.ts new file mode 100644 index 000000000..1fbf620e9 --- /dev/null +++ b/src/app/components/dashboard/components/indicator/index.ts @@ -0,0 +1,174 @@ +/** + * Copyright (c) 2022 Hengyang Zhang + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +import DataItem from "@entity/dto/data-item" +import metaService from "@service/meta-service" +import PeriodDatabase from "@db/period-database" +import timerService from "@service/timer-service" +import { getStartOfDay, MILL_PER_DAY } from "@util/time" +import { defineComponent, h, ref, Ref } from "vue" +import PeriodInfo, { MILL_PER_MINUTE } from "@entity/dto/period-info" +import { groupBy } from "@util/array" +import NumberGrow from "@app/components/common/number-grow" +import "./style" +import { tN } from "@app/locale" +import IndicatorHeaderIcon from "./header-icon" + +const CONTAINER_ID = "__timer-indicator-container" + +const periodDatabase = new PeriodDatabase(chrome.storage.local) + +type _Value = { + installedDays?: number + sites: number + visits: number + browsingTime: number + most2Hour: number +} + +/** + * @return days used + */ +function calculateInstallDays(installTime: Date, now: Date): number { + const deltaMills = getStartOfDay(now).getTime() - getStartOfDay(installTime).getTime() + return Math.round(deltaMills / MILL_PER_DAY) +} + +async function query(): Promise<_Value> { + const allData: DataItem[] = await timerService.select({}) + const hostSet = new Set() + let visits = 0 + let browsingTime = 0 + allData.forEach(({ host, focus, time }) => { + hostSet.add(host) + visits += time + browsingTime += focus + }) + const periodInfos: PeriodInfo[] = await periodDatabase.getAll() + // Order [0, 95] + const averageTimePerPeriod: { [order: number]: number } = groupBy(periodInfos, + p => p.order, + (grouped: PeriodInfo[]) => { + const periodMills = grouped.map(p => p.milliseconds) + if (!periodMills.length) { + return 0 + } + return Math.floor(periodMills.reduce((a, b) => a + b, 0) / periodMills.length) + } + ) + // Merged per 2 hours + const averageTimePer2Hours: { [idx: number]: number } = groupBy(Object.entries(averageTimePerPeriod), + ([order]) => Math.floor(parseInt(order) / 8), + averages => averages.map(a => a[1]).reduce((a, b) => a + b, 0) + ) + // The two most frequent online hours + const most2Hour: number = parseInt( + Object.entries(averageTimePer2Hours) + .sort((a, b) => a[1] - b[1]) + .reverse()[0]?.[0] + ) + + const result: _Value = { + sites: hostSet?.size || 0, + visits, + browsingTime, + most2Hour + } + // 1. Get install time from metaService + let installTime = await metaService.getInstallTime() + if (!installTime) { + // 2. if not exist, calculate from all data items + const firstDate = allData.map(a => a.date).sort()[0] + if (firstDate && firstDate.length === 8) { + const year = parseInt(firstDate.substr(0, 4)) + const month = parseInt(firstDate.substr(4, 2)) - 1 + const date = parseInt(firstDate.substr(6, 2)) + installTime = new Date(year, month, date) + } + } + installTime && (result.installedDays = calculateInstallDays(installTime, new Date())) + return result +} + + +function renderInstalledDays(value: number) { + return h('div', + { class: 'indicator-label' }, + tN(msg => msg.dashboard.indicator.installedDays, { + number: h(NumberGrow, { value, duration: 1.5 }) + }) + ) +} + +function renderVisits(siteNum: number, visitNum: number) { + const duration = 1.75 + return h('div', + { class: 'indicator-label' }, + tN(msg => msg.dashboard.indicator.visitCount, { + visit: h(NumberGrow, { value: visitNum, duration }), + site: h(NumberGrow, { value: siteNum, duration }) + }) + ) +} + +function renderBrowsingMinute(browsingMinute: number) { + return h('div', + { class: 'indicator-label' }, + tN(msg => msg.dashboard.indicator.browsingTime, { + minute: h(NumberGrow, { value: browsingMinute, duration: 2 }) + }) + ) +} + +function renderMostUse(most2Hour: number) { + const startHour = most2Hour * 2 + const endHour = most2Hour * 2 + 2 + const duration = 2.25 + return h('div', + { class: 'indicator-label' }, + tN(msg => msg.dashboard.indicator.mostUse, { + start: h(NumberGrow, { value: startHour, duration }), + end: h(NumberGrow, { value: endHour, duration }) + }) + ) +} + +const _default = defineComponent({ + name: "Indicator", + setup() { + const installedDays: Ref = ref() + const siteCount: Ref = ref(0) + const visitCount: Ref = ref(0) + const browsingMinutes: Ref = ref(0) + const most2Hour: Ref = ref(0) + query().then(value => { + installedDays.value = value.installedDays + siteCount.value = value.sites + visitCount.value = value.visits + browsingMinutes.value = Math.floor(value.browsingTime / MILL_PER_MINUTE) + most2Hour.value = value.most2Hour + }) + return () => { + const items = [ + renderVisits(siteCount.value, visitCount.value), + renderBrowsingMinute(browsingMinutes.value), + renderMostUse(most2Hour.value) + ] + const installedDaysVal = installedDays.value + installedDaysVal && items.splice(0, 0, renderInstalledDays(installedDaysVal)) + return h('div', { + id: CONTAINER_ID, + class: 'chart-container' + }, [ + h(IndicatorHeaderIcon), + ...items + ]) + } + } +}) + +export default _default \ No newline at end of file diff --git a/src/app/components/dashboard/components/indicator/style.sass b/src/app/components/dashboard/components/indicator/style.sass new file mode 100644 index 000000000..2f81c45db --- /dev/null +++ b/src/app/components/dashboard/components/indicator/style.sass @@ -0,0 +1,22 @@ +/** + * Copyright (c) 2022 Hengyang Zhang + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ +.indicator-icon-header + height: 50px + margin-bottom: 10px + i + height: 90% + width: 100% + svg + height: 100% + width: 100% + +.indicator-label + font-size: 15px + padding-left: 10px + padding-bottom: 10px + &:last-child + padding-bottom: 0px diff --git a/src/app/components/dashboard/components/top-k-visit.ts b/src/app/components/dashboard/components/top-k-visit.ts new file mode 100644 index 000000000..ea5f4b3ac --- /dev/null +++ b/src/app/components/dashboard/components/top-k-visit.ts @@ -0,0 +1,119 @@ +/** + * Copyright (c) 2022 Hengyang Zhang + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +import type { ECharts } from "echarts/core" +import { init, use, ComposeOption } from "echarts/core" +import { PieChart, PieSeriesOption } from "echarts/charts" +import { + TitleComponent, TitleComponentOption, + TooltipComponent, TooltipComponentOption, +} from "echarts/components" + +use([PieChart, TitleComponent, TooltipComponent]) + +import timerService, { SortDirect, TimerQueryParam } from "@service/timer-service" +import { MILL_PER_DAY } from "@util/time" +import { ElLoading } from "element-plus" +import { defineComponent, h, onMounted, ref, Ref } from "vue" +import DataItem from "@entity/dto/data-item" +import { BASE_TITLE_OPTION } from "../common" +import { t } from "@app/locale" + +const CONTAINER_ID = '__timer_dashboard_top_k_visit' +const TOP_NUM = 6 +const DAY_NUM = 30 + +type EcOption = ComposeOption< + | PieSeriesOption + | TitleComponentOption + | TooltipComponentOption +> + +type _Value = { + name: string + value: number +} + +function optionOf(data: _Value[]): EcOption { + return { + title: { + ...BASE_TITLE_OPTION, + text: t(msg => msg.dashboard.topK.title, { k: TOP_NUM, day: DAY_NUM }) + }, + tooltip: { + show: true, + formatter(params: any) { + const visit = params.data?.value || 0 + const host = params.data?.name || '' + return t(msg => msg.dashboard.topK.tooltip, { visit, host }) + } + }, + series: { + top: '20%', + height: '80%', + name: "Monthly Top 10", + type: 'pie', + radius: [20, 80], + center: ['50%', '50%'], + roseType: 'area', + itemStyle: { + borderRadius: 7 + }, + data: data + } + } +} + +class ChartWrapper { + instance: ECharts + + init(container: HTMLDivElement) { + this.instance = init(container) + } + render(data: _Value[], loading: { close: () => void }) { + const option = optionOf(data) + this.instance.setOption(option) + loading.close() + } +} + +const _default = defineComponent({ + name: "TopKVisit", + setup() { + const now = new Date() + const startTime: Date = new Date(now.getTime() - MILL_PER_DAY * DAY_NUM) + + const chart: Ref = ref() + const chartWrapper: ChartWrapper = new ChartWrapper() + + onMounted(async () => { + const loading = ElLoading.service({ + target: `#${CONTAINER_ID}`, + }) + chartWrapper.init(chart.value) + const query: TimerQueryParam = { + date: [startTime, now], + sort: "time", + sortOrder: SortDirect.DESC, + mergeDate: true, + } + const top: DataItem[] = (await timerService.selectByPage(query, { pageNum: 1, pageSize: TOP_NUM })).list + const data: _Value[] = top.map(({ time, host }) => ({ name: host, value: time })) + for (let realSize = top.length; realSize < TOP_NUM; realSize++) { + data.push({ name: '', value: 0 }) + } + chartWrapper.render(data, loading) + }) + return () => h('div', { + id: CONTAINER_ID, + class: 'chart-container', + ref: chart, + }) + } +}) + +export default _default \ No newline at end of file diff --git a/src/app/components/dashboard/components/week-on-week.ts b/src/app/components/dashboard/components/week-on-week.ts new file mode 100644 index 000000000..7c157cfec --- /dev/null +++ b/src/app/components/dashboard/components/week-on-week.ts @@ -0,0 +1,188 @@ +/** + * Copyright (c) 2022 Hengyang Zhang + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ +import type { Ref } from "vue" +import type { TimerQueryParam } from "@service/timer-service" +import type { ECharts } from "echarts/core" + +import { init, use, ComposeOption } from "echarts/core" +import { CandlestickChart, CandlestickSeriesOption } from "echarts/charts" +import { + GridComponent, GridComponentOption, + TitleComponent, TitleComponentOption, + TooltipComponent, TooltipComponentOption, +} from "echarts/components" + +use([CandlestickChart, GridComponent, TitleComponent, TooltipComponent]) + +import { formatPeriodCommon, MILL_PER_DAY } from "@util/time" +import { ElLoading } from "element-plus" +import { defineComponent, h, onMounted, ref } from "vue" +import timerService from "@service/timer-service" +import DataItem from "@entity/dto/data-item" +import { groupBy, sum } from "@util/array" +import { BASE_TITLE_OPTION } from "../common" +import { t } from "@app/locale" + +type EcOption = ComposeOption< + | CandlestickSeriesOption + | GridComponentOption + | TitleComponentOption + | TooltipComponentOption +> + +const PERIOD_WIDTH = 7 + +const TOP_NUM = 5 + +const CONTAINER_ID = '__timer_dashboard_week_on_week' + +type _Value = { + lastPeriod: number + thisPeriod: number + delta: number + host: string +} + +function optionOf(lastPeriodItems: DataItem[], thisPeriodItems: DataItem[]): EcOption { + const lastPeriodMap: { [host: string]: number } = groupBy(lastPeriodItems, + item => item.host, + grouped => Math.floor(sum(grouped.map(item => item.focus)) / 1000) + ) + + const thisPeriodMap: { [host: string]: number } = groupBy(thisPeriodItems, + item => item.host, + grouped => Math.floor(sum(grouped.map(item => item.focus)) / 1000) + ) + const values: { [host: string]: _Value } = {} + // 1st, iterate this period + Object.entries(thisPeriodMap) + .forEach(([host, thisPeriod]) => { + const lastPeriod = lastPeriodMap[host] || 0 + const delta = thisPeriod - lastPeriod + values[host] = { thisPeriod, lastPeriod, delta, host } + }) + // 2nd, iterate last period + Object.entries(lastPeriodMap) + .filter(([host]) => !values[host]) + .forEach(([host, lastPeriod]) => { + const thisPeriod = thisPeriodMap[host] || 0 + const delta = thisPeriod - lastPeriod + values[host] = { thisPeriod, lastPeriod, delta, host } + }) + // 3rd, sort by delta + const sortedValues = Object.values(values) + .sort((a, b) => Math.abs(a.delta) - Math.abs(b.delta)) + .reverse() + const topK = sortedValues.slice(0, TOP_NUM) + // 4th, sort by max value + topK.sort((a, b) => Math.max(a.lastPeriod, a.thisPeriod) - Math.max(b.lastPeriod, b.thisPeriod)) + + const positiveColor = getComputedStyle(document.body).getPropertyValue('--el-color-danger') + const negativeColor = getComputedStyle(document.body).getPropertyValue('--timer-dashboard-heatmap-color-c') + return { + title: { + ...BASE_TITLE_OPTION, + text: t(msg => msg.dashboard.weekOnWeek.title, { k: TOP_NUM }) + }, + tooltip: { + trigger: 'axis', + axisPointer: { + type: 'shadow' + }, + formatter(params: any) { + const data = params?.[0]?.data + const lastPeriod = data[1] || 0 + const thisPeriod = data[2] || 0 + const lastLabel = t(msg => msg.dashboard.weekOnWeek.lastBrowse, { time: formatPeriodCommon(lastPeriod * 1000) }) + const thisLabel = t(msg => msg.dashboard.weekOnWeek.thisBrowse, { time: formatPeriodCommon(thisPeriod * 1000) }) + const deltaLabel = t(msg => msg.dashboard.weekOnWeek.wow, { + delta: formatPeriodCommon(Math.abs(thisPeriod - lastPeriod) * 1000), + state: t(msg => msg.dashboard.weekOnWeek[thisPeriod < lastPeriod ? 'decline' : 'increase']) + }) + return `${lastLabel}
${thisLabel}
${deltaLabel}` + } + }, + grid: { + left: '7%', + right: '3%', + bottom: '12%', + }, + xAxis: { + type: 'category', + name: 'Seconds', + splitLine: { show: false }, + data: topK.map(a => a.host), + axisLabel: { + interval: 0 + }, + }, + yAxis: { + type: 'value', + }, + series: [{ + type: 'candlestick', + barMaxWidth: '40px', + itemStyle: { + color: positiveColor, + borderColor: positiveColor, + borderColor0: negativeColor, + color0: negativeColor, + }, + data: topK.map(a => [a.lastPeriod, a.thisPeriod, a.lastPeriod, a.thisPeriod]) + }] + } +} + +class ChartWrapper { + instance: ECharts + + init(container: HTMLDivElement) { + this.instance = init(container) + } + + render(option: EcOption, loading: { close: () => void }) { + this.instance.setOption(option) + loading.close() + } +} + +const _default = defineComponent({ + name: "WeekOnWeek", + setup() { + const now = new Date() + const lastPeriodStart = new Date(now.getTime() - MILL_PER_DAY * PERIOD_WIDTH * 2) + const lastPeriodEnd = new Date(lastPeriodStart.getTime() + MILL_PER_DAY * (PERIOD_WIDTH - 1)) + const thisPeriodStart = new Date(now.getTime() - MILL_PER_DAY * PERIOD_WIDTH) + // Not includes today + const thisPeriodEnd = new Date(now.getTime() - MILL_PER_DAY) + + const chartWrapper: ChartWrapper = new ChartWrapper() + const chart: Ref = ref() + onMounted(async () => { + const loading = ElLoading.service({ + target: `#${CONTAINER_ID}`, + }) + chartWrapper.init(chart.value) + const query: TimerQueryParam = { + date: [lastPeriodStart, lastPeriodEnd], + mergeDate: true, + } + const lastPeriodItems: DataItem[] = await timerService.select(query) + query.date = [thisPeriodStart, thisPeriodEnd] + const thisPeriodItems: DataItem[] = await timerService.select(query) + const option = optionOf(lastPeriodItems, thisPeriodItems) + chartWrapper.render(option, loading) + }) + return () => h('div', { + id: CONTAINER_ID, + class: 'chart-container', + ref: chart, + }) + } +}) + +export default _default \ No newline at end of file diff --git a/src/app/components/dashboard/feedback.ts b/src/app/components/dashboard/feedback.ts new file mode 100644 index 000000000..81d325df3 --- /dev/null +++ b/src/app/components/dashboard/feedback.ts @@ -0,0 +1,41 @@ +/** + * Copyright (c) 2022 Hengyang Zhang + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +import { t } from "@app/locale" +import { Headset } from "@element-plus/icons-vue" +import { DASHBOARD_FEEDBACK_PAGE } from "@util/constant/url" +import { Effect, ElButton, ElTooltip } from "element-plus" +import { defineComponent, h } from "vue" + +const style: Partial = { + width: '100%', + paddingTop: '10px', + paddingRight: '40px', + height: '100px', + textAlign: 'right' +} + +const _default = defineComponent({ + name: "DashboardFeedback", + render: () => h('div', { + style + }, h(ElTooltip, { + placement: 'top', + content: t(msg => msg.dashboard.feedback.tooltip), + effect: Effect.LIGHT, + }, () => h(ElButton, { + type: "info", + size: "mini", + icon: Headset, + round: true, + onClick: () => chrome.tabs.create({ + url: DASHBOARD_FEEDBACK_PAGE + }) + }, () => t(msg => msg.dashboard.feedback.button)))) +}) + +export default _default \ No newline at end of file diff --git a/src/app/components/dashboard/index.ts b/src/app/components/dashboard/index.ts new file mode 100644 index 000000000..6d5e3c150 --- /dev/null +++ b/src/app/components/dashboard/index.ts @@ -0,0 +1,30 @@ +/** + * Copyright (c) 2022 Hengyang Zhang + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +import { defineComponent, h } from "vue" +import ContentContainer from "@app/components/common/content-container" +import DashboardRow1 from './row1' +import DashboardRow2 from './row2' +import DashboardFeedback from "./feedback" +import "./style/index" +import { locale } from "@util/i18n" + +const _default = defineComponent({ + name: 'Dashboard', + setup() { + return () => h(ContentContainer, {}, () => { + const items = [ + h(DashboardRow1), + h(DashboardRow2) + ] + locale === "zh_CN" && (items.push(h(DashboardFeedback))) + return items + }) + } +}) + +export default _default \ No newline at end of file diff --git a/src/app/components/dashboard/row1.ts b/src/app/components/dashboard/row1.ts new file mode 100644 index 000000000..7d573ebd0 --- /dev/null +++ b/src/app/components/dashboard/row1.ts @@ -0,0 +1,35 @@ +/** + * Copyright (c) 2022 Hengyang Zhang + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +import { ElRow } from "element-plus" +import { defineComponent, h } from "vue" +import DashboardCard from './card' +import Indicator from './components/indicator' +import WeekOnWeek from './components/week-on-week' +import TopKVisit from './components/top-k-visit' + +const _default = defineComponent({ + name: "DashboardRow1", + render() { + return h(ElRow, { + gutter: 40, + style: { height: '290px' } + }, () => [ + h(DashboardCard, { + span: 4 + }, () => h(Indicator)), + h(DashboardCard, { + span: 12 + }, () => h(WeekOnWeek)), + h(DashboardCard, { + span: 8 + }, () => h(TopKVisit)), + ]) + } +}) + +export default _default \ No newline at end of file diff --git a/src/app/components/dashboard/row2.ts b/src/app/components/dashboard/row2.ts new file mode 100644 index 000000000..b1fec1e52 --- /dev/null +++ b/src/app/components/dashboard/row2.ts @@ -0,0 +1,28 @@ +/** + * Copyright (c) 2022 Hengyang Zhang + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +import { ElRow } from "element-plus" +import { defineComponent, h } from "vue" +import DashboardCard from './card' +import CalendarHeatMap from './components/calendar-heat-map' + + +const _default = defineComponent({ + name: "DashboardRow1", + render() { + return h(ElRow, { + gutter: 40, + style: { height: '280px' } + }, () => [ + h(DashboardCard, { + span: 24 + }, () => h(CalendarHeatMap)) + ]) + } +}) + +export default _default \ No newline at end of file diff --git a/src/app/components/dashboard/style/index.sass b/src/app/components/dashboard/style/index.sass new file mode 100644 index 000000000..b44bf7732 --- /dev/null +++ b/src/app/components/dashboard/style/index.sass @@ -0,0 +1,27 @@ +/** + * Copyright (c) 2022 Hengyang Zhang + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +.el-row + margin-bottom: 50px + .el-col + height: 100% + &:last-child + margin-bottom: 0 + +.el-card__body + padding: 20px + width: calc(100% - 40px) + height: calc(100% - 40px) + + .chart-container + height: 100% + +\:root + --timer-dashboard-heatmap-color-a: #9be9a8 + --timer-dashboard-heatmap-color-b: #40c463 + --timer-dashboard-heatmap-color-c: #30a14e + --timer-dashboard-heatmap-color-d: #216e39 diff --git a/src/app/components/trend/components/chart/index.ts b/src/app/components/trend/components/chart/index.ts new file mode 100644 index 000000000..6ba2679e5 --- /dev/null +++ b/src/app/components/trend/components/chart/index.ts @@ -0,0 +1,56 @@ +/** + * Copyright (c) 2021 Hengyang Zhang + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +import type { ComputedRef, Ref } from "vue" +import type { TimerQueryParam } from "@service/timer-service" + +import { computed, defineComponent, h, onMounted, ref, watch } from "vue" +import timerService, { SortDirect } from "@service/timer-service" +import HostOptionInfo from "../../host-option-info" +import DataItem from "@entity/dto/data-item" +import ChartWrapper from "./wrapper" + +const _default = defineComponent({ + name: "TrendChart", + setup(_, ctx) { + const elRef: Ref = ref() + const host: Ref = ref(HostOptionInfo.empty()) + const dateRange: Ref> = ref([]) + const chartWrapper: ChartWrapper = new ChartWrapper() + + const queryParam: ComputedRef = computed(() => ({ + // If the host is empty, no result will be queried with this param. + host: host.value.host === '' ? '___foo_bar' : host.value.host, + mergeHost: host.value.merged, + fullHost: true, + sort: 'date', + sortOrder: SortDirect.ASC + })) + + async function queryAndRender() { + const row: DataItem[] = await timerService.select(queryParam.value) + chartWrapper.render(host.value, dateRange.value, row) + } + + watch(host, () => queryAndRender()) + watch(dateRange, () => queryAndRender()) + + ctx.expose({ + setDomain: (key: string) => host.value = HostOptionInfo.from(key), + setDateRange: (newVal: Date[]) => dateRange.value = newVal + }) + + onMounted(() => { + chartWrapper.init(elRef.value) + queryAndRender() + }) + + return () => h('div', { class: 'chart-container', ref: elRef }) + } +}) + +export default _default diff --git a/src/app/components/trend/components/chart/wrapper.ts b/src/app/components/trend/components/chart/wrapper.ts new file mode 100644 index 000000000..e89d875be --- /dev/null +++ b/src/app/components/trend/components/chart/wrapper.ts @@ -0,0 +1,205 @@ +/** + * Copyright (c) 2022 Hengyang Zhang + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +import type { ECharts, ComposeOption } from "echarts/core" +import type { LineSeriesOption } from "echarts/charts" +import type { + GraphicComponentOption, + LegendComponentOption, + TitleComponentOption, + TooltipComponentOption, + ToolboxComponentOption, +} from "echarts/components" + +import { init, use } from "echarts/core" +import { LineChart } from "echarts/charts" +import { GridComponent, LegendComponent, TitleComponent, TooltipComponent, ToolboxComponent } from "echarts/components" +import { CanvasRenderer } from "echarts/renderers" +import { t } from "@app/locale" +import { formatPeriodCommon, formatTime, MILL_PER_DAY } from "@util/time" +import HostOptionInfo from "../../host-option-info" +import DataItem from "@entity/dto/data-item" +import hostAliasService from "@service/host-alias-service" +import HostAlias from "@entity/dao/host-alias" + +use([ + LineChart, + GridComponent, + LegendComponent, + TitleComponent, + ToolboxComponent, + TooltipComponent, + CanvasRenderer, +]) + +type EcOption = ComposeOption< + | LineSeriesOption + | GraphicComponentOption + | LegendComponentOption + | TitleComponentOption + | ToolboxComponentOption + | TooltipComponentOption +> + +const TITLE = t(msg => msg.trend.history.title) +const DEFAULT_SUB_TITLE = t(msg => msg.trend.defaultSubTitle) +const SAVE_AS_IMAGE = t(msg => msg.trend.saveAsImageTitle) + +const TIME_UNIT = t(msg => msg.trend.history.timeUnit) +const NUMBER_UNIT = t(msg => msg.trend.history.numberUnit) + +function formatTimeOfEchart(params: any): string { + const format = params instanceof Array ? params[0] : params + const { seriesName, name, value } = format + return `${seriesName}
${name} - ${formatPeriodCommon((typeof value === 'number' ? value : 0) * 1000)}` +} + +const mill2Second = (mill: number) => Math.floor((mill || 0) / 1000) + +function optionOf( + xAxisData: string[], + subtext: string, + [focusData, totalData, timeData]: number[][] +) { + const option: EcOption = { + backgroundColor: 'rgba(0,0,0,0)', + grid: { top: '100' }, + title: { text: TITLE, subtext, left: 'center' }, + tooltip: { trigger: 'item' }, + toolbox: { + feature: { + saveAsImage: { + show: true, + title: SAVE_AS_IMAGE, + excludeComponents: ['toolbox'], + pixelRatio: 1, + backgroundColor: '#fff' + } + } + }, + xAxis: { type: 'category', data: xAxisData }, + yAxis: [ + { name: TIME_UNIT, type: 'value' }, + { name: NUMBER_UNIT, type: 'value' } + ], + legend: { + left: 'left', + data: [t(msg => msg.item.total), t(msg => msg.item.focus), t(msg => msg.item.time)] + }, + series: [{ + // run time + name: t(msg => msg.item.total), + data: totalData, + yAxisIndex: 0, + type: 'line', + smooth: true, + tooltip: { formatter: formatTimeOfEchart } + }, { + name: t(msg => msg.item.focus), + data: focusData, + yAxisIndex: 0, + type: 'line', + smooth: true, + tooltip: { formatter: formatTimeOfEchart } + }, { + name: t(msg => msg.item.time), + data: timeData, + yAxisIndex: 1, + type: 'line', + smooth: true, + tooltip: { + formatter: (params: any) => { + const format = params instanceof Array ? params[0] : params + const { seriesName, name, value } = format + return `${seriesName}
${name} - ${value}` + } + } + }] + } + return option +} + + +// Get the timestamp of one timestamp of date +const timestampOf = (d: Date) => d.getTime() + +/** +* Get the x-axis of date +*/ +function getAxias(format: string, dateRange: Date[] | undefined): string[] { + if (!dateRange || !dateRange.length) { + // @since 0.0.9 + // The dateRange is cleared, return empty data + return [] + } + const xAxisData = [] + const startTime = timestampOf(dateRange[0]) + const endTime = timestampOf(dateRange[1]) + for (let time = startTime; time <= endTime; time += MILL_PER_DAY) { + xAxisData.push(formatTime(time, format)) + } + return xAxisData +} + +async function processSubtitle(host: HostOptionInfo) { + let subtitle = host.toString() + if (!subtitle) { + return DEFAULT_SUB_TITLE + } + if (!host.merged) { + // If not merged, append the site name to the original subtitle + // @since 0.9.0 + const hostAlias: HostAlias = await hostAliasService.get(host.host) + const siteName = hostAlias?.name + siteName && (subtitle += ` / ${siteName}`) + } + return subtitle +} + +class ChartWrapper { + instance: ECharts + + init(container: HTMLDivElement) { + this.instance = init(container) + } + + async render(host: HostOptionInfo, dateRange: Date[], row: DataItem[]) { + // 1. x-axis data + let xAxisData: string[], allDates: string[] + if (!host || !dateRange || dateRange.length !== 2) { + xAxisData = [] + allDates = [] + } else { + xAxisData = getAxias('{m}/{d}', dateRange) + allDates = getAxias('{y}{m}{d}', dateRange) + } + + // 2. subtitle + const subtitle = await processSubtitle(host) + + // 3. series data + const focusData = [] + const totalData = [] + const timeData = [] + + const dateInfoMap = {} + row.forEach(row => dateInfoMap[row.date] = row) + + allDates.forEach(date => { + const row = dateInfoMap[date] || {} + focusData.push(mill2Second(row.focus)) + totalData.push(mill2Second(row.total)) + timeData.push(row.time || 0) + }) + + const option: EcOption = optionOf(xAxisData, subtitle, [focusData, totalData, timeData]) + + this.instance?.setOption(option) + } +} + +export default ChartWrapper diff --git a/src/app/components/trend/components/trend-chart.ts b/src/app/components/trend/components/trend-chart.ts deleted file mode 100644 index 5cea12413..000000000 --- a/src/app/components/trend/components/trend-chart.ts +++ /dev/null @@ -1,215 +0,0 @@ -/** - * Copyright (c) 2021 Hengyang Zhang - * - * This software is released under the MIT License. - * https://opensource.org/licenses/MIT - */ - -import { EChartOption, ECharts, EChartTitleOption, init } from "echarts" -import { computed, ComputedRef, defineComponent, h, onMounted, ref, Ref, watch } from "vue" -import { t } from "@app/locale" -import timerService, { TimerQueryParam, SortDirect } from "@service/timer-service" -import { formatPeriodCommon, formatTime, MILL_PER_DAY } from "@util/time" -import HostOptionInfo from "../host-option-info" -import DataItem from "@entity/dto/data-item" -import hostAliasService from "@service/host-alias-service" -import HostAlias from "@entity/dao/host-alias" - -// Get the timestamp of one timestamp of date -const timestampOf = (d: Date) => d.getTime() - -const mill2Second = (mill: number) => Math.floor((mill || 0) / 1000) - -let chartInstance: ECharts -const formatTimeOfEchart = (params: EChartOption.Tooltip.Format | EChartOption.Tooltip.Format[]) => { - const format: EChartOption.Tooltip.Format = params instanceof Array ? params[0] : params - const { seriesName, name, value } = format - return `${seriesName}
${name} - ${formatPeriodCommon((typeof value === 'number' ? value : 0) * 1000)}` -} - -const defaultSubTitle = t(msg => msg.trend.defaultSubTitle) - -const options: EChartOption = { - backgroundColor: 'rgba(0,0,0,0)', - grid: { top: '100' }, - title: { - text: t(msg => msg.trend.history.title), - subtext: defaultSubTitle, - left: 'center' - }, - tooltip: { - trigger: 'item' - }, - toolbox: { - feature: { - saveAsImage: { - show: true, - title: t(msg => msg.trend.saveAsImageTitle), - excludeComponents: ['toolbox'], - pixelRatio: 1, - backgroundColor: '#fff' - } - } - }, - xAxis: { - type: 'category', - data: [] - }, - yAxis: [ - { name: t(msg => msg.trend.history.timeUnit), type: 'value' }, - { name: t(msg => msg.trend.history.numberUnit), type: 'value' } - ], - legend: { - left: 'left', - data: [t(msg => msg.item.total), t(msg => msg.item.focus), t(msg => msg.item.time)] - }, - series: [ - // run time - { - name: t(msg => msg.item.total), - data: [], - yAxisIndex: 0, - type: 'line', - smooth: true, - tooltip: { formatter: formatTimeOfEchart } - }, - { - name: t(msg => msg.item.focus), - data: [], - yAxisIndex: 0, - type: 'line', - smooth: true, - tooltip: { formatter: formatTimeOfEchart } - }, - { - name: t(msg => msg.item.time), - data: [], - yAxisIndex: 1, - type: 'line', - smooth: true, - tooltip: { - formatter: (params: EChartOption.Tooltip.Format | EChartOption.Tooltip.Format[]) => { - const format: EChartOption.Tooltip.Format = params instanceof Array ? params[0] : params - const { seriesName, name, value } = format - return `${seriesName}
${name} - ${value}` - } - } - } - ] -} - -const renderChart = () => chartInstance && chartInstance.setOption(options, true) - -/** -* Get the x-axis of date -*/ -function getAxias(format: string, dateRange: Date[] | undefined) { - if (!dateRange || !dateRange.length) { - // @since 0.0.9 - // The dateRange is cleared, return empty data - return [] - } - const xAxisData = [] - const startTime = timestampOf(dateRange[0]) - const endTime = timestampOf(dateRange[1]) - for (let time = startTime; time <= endTime; time += MILL_PER_DAY) { - xAxisData.push(formatTime(time, format)) - } - return xAxisData -} - -/** - * Update the x-axis - */ -function updateXAxis(hostOptionInfo: HostOptionInfo, dateRange: Date[]) { - const xAxis: EChartOption.XAxis = options.xAxis as EChartOption.XAxis - const host = hostOptionInfo.host - if (!host || !dateRange || dateRange.length !== 2) { - xAxis.data = [] - } - xAxis.data = getAxias('{m}/{d}', dateRange) -} - - -async function queryData(queryParam: TimerQueryParam, host: HostOptionInfo, dateRange: Date[]) { - const rows: DataItem[] = await timerService.select(queryParam) - const dateInfoMap = {} - rows.forEach(row => dateInfoMap[row.date] = row) - const allXAxis = getAxias('{y}{m}{d}', dateRange) - - const focusData = [] - const totalData = [] - const timeData = [] - - allXAxis.forEach(date => { - const row = dateInfoMap[date] || {} - focusData.push(mill2Second(row.focus)) - totalData.push(mill2Second(row.total)) - timeData.push(row.time || 0) - }) - - await processSubtitle(host) - - options.series[0].data = totalData - options.series[1].data = focusData - options.series[2].data = timeData - renderChart() -} - -async function processSubtitle(host: HostOptionInfo) { - const titleOption = options.title as EChartTitleOption - let subtitle = host.toString() - if (!subtitle) { - titleOption.subtext = defaultSubTitle - return - } - if (!host.merged) { - // If not merged, append the site name to the original subtitle - // @since 0.9.0 - const hostAlias: HostAlias = await hostAliasService.get(host.host) - const siteName = hostAlias?.name - siteName && (subtitle += ` / ${siteName}`) - } - titleOption.subtext = subtitle -} - -const _default = defineComponent({ - name: "TrendChart", - setup(_, ctx) { - const chart: Ref = ref() - const host: Ref = ref(HostOptionInfo.empty()) - const dateRange: Ref> = ref([]) - - const queryParam: ComputedRef = computed(() => { - return { - // If the host is empty, no result will be queried with this param. - host: host.value.host === '' ? '___foo_bar' : host.value.host, - mergeHost: host.value.merged, - fullHost: true, - sort: 'date', - sortOrder: SortDirect.ASC - } - }) - - watch(host, () => queryData(queryParam.value, host.value, dateRange.value)) - watch(dateRange, () => { - updateXAxis(host.value, dateRange.value) - queryData(queryParam.value, host.value, dateRange.value) - }) - - ctx.expose({ - setDomain: (key: string) => host.value = HostOptionInfo.from(key), - setDateRange: (newVal: Date[]) => dateRange.value = newVal - }) - - onMounted(() => { - chartInstance = init(chart.value) - updateXAxis(host.value, dateRange.value) - renderChart() - }) - - return () => h('div', { class: 'chart-container', ref: chart }) - } -}) - -export default _default diff --git a/src/app/components/trend/index.ts b/src/app/components/trend/index.ts index 0f13a3501..cb39b844c 100644 --- a/src/app/components/trend/index.ts +++ b/src/app/components/trend/index.ts @@ -5,12 +5,15 @@ * https://opensource.org/licenses/MIT */ -import { defineComponent, h, onMounted, Ref, ref, watch } from "vue" +import type { Ref } from "vue" +import type { FilterProps } from "./components/filter" + +import { defineComponent, h, onMounted, ref, watch } from "vue" import { useRoute, useRouter } from "vue-router" import { daysAgo } from "@util/time" import ContentContainer from "../common/content-container" -import DomainTrend from "./components/trend-chart" -import filterContainer, { addToFilterOption, FilterProps } from "./components/filter" +import TrendChart from "./components/chart" +import filterContainer, { addToFilterOption } from "./components/filter" import HostOptionInfo from "./host-option-info" type QueryParam = { @@ -58,7 +61,7 @@ const _default = defineComponent({ return () => h(ContentContainer, {}, { filter: () => filterContainer(filterProps), - content: () => h(DomainTrend, { ref: chart }) + content: () => h(TrendChart, { ref: chart }) }) } }) diff --git a/src/app/layout/menu.ts b/src/app/layout/menu.ts index f2cabff3a..372c845b4 100644 --- a/src/app/layout/menu.ts +++ b/src/app/layout/menu.ts @@ -11,7 +11,7 @@ import { RouteLocationNormalizedLoaded, Router, useRoute, useRouter } from "vue- import { I18nKey, t } from "@app/locale" import { MenuMessage } from "@app/locale/components/menu" import { HOME_PAGE, MEAT_URL, TRANSLATION_ISSUE_PAGE, FEEDBACK_QUESTIONNAIRE } from "@util/constant/url" -import { Aim, Calendar, ChatSquare, Folder, Food, HotWater, MagicStick, Rank, SetUp, Stopwatch, Sugar, Tickets, Timer } from "@element-plus/icons-vue" +import { Aim, Calendar, ChatSquare, Folder, Food, HotWater, MagicStick, Rank, SetUp, Stopwatch, Sugar, Tickets, Timer, TrendCharts } from "@element-plus/icons-vue" import ElementIcon from "../element-ui/icon" import { locale } from "@util/i18n" @@ -78,6 +78,10 @@ function generateMenus(): _MenuGroup[] { return [{ title: 'data', children: [{ + title: 'dashboard', + route: '/data/dashboard', + icon: TrendCharts + }, { title: 'dataReport', route: '/data/report', icon: Calendar @@ -181,7 +185,7 @@ const _default = defineComponent({ current: useRoute() }) - onMounted(() => document.title = t(msg => msg.menu.data)) + onMounted(() => document.title = t(msg => msg.menu.dashboard)) return () => h(ElMenu, { defaultActive: routeProps.current.path }, diff --git a/src/app/locale/components/dashboard.ts b/src/app/locale/components/dashboard.ts new file mode 100644 index 000000000..8bf682ed3 --- /dev/null +++ b/src/app/locale/components/dashboard.ts @@ -0,0 +1,161 @@ +/** + * Copyright (c) 2022 Hengyang Zhang + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +import { Messages } from "@util/i18n" + +export type DashboardMessage = { + heatMap: { + title0: string + title1: string + tooltip0: string + tooltip1: string + } + topK: { + title: string + tooltip: string + } + indicator: { + installedDays: string + visitCount: string + browsingTime: string + mostUse: string + } + weekOnWeek: { + title: string + lastBrowse: string + thisBrowse: string + wow: string + increase: string + decline: string + }, + feedback: { + button: string + tooltip: string + } +} + +// Not display if not zh_CN +const EMPTY_FEEDBACK = { + button: '', + tooltip: '' +} + +const _default: Messages = { + zh_CN: { + heatMap: { + title0: '近一年上网总时长超过 {hour} 小时', + title1: '近一年上网总时长不足 1 小时', + tooltip0: '{year}/{month}/{day} 浏览网页 {minute} 分钟', + tooltip1: '{year}/{month}/{day} 浏览网页 {hour} 小时 {minute} 分钟', + }, + topK: { + title: '近 {day} 天最常访问 TOP {k}', + tooltip: '访问 {host} {visit} 次', + }, + indicator: { + installedDays: '已使用 {number} 天', + visitCount: '累计访问过 {site} 个网站共 {visit} 次', + browsingTime: '浏览时长超过 {minute} 分钟', + mostUse: '最喜欢在 {start} 点至 {end} 点之间打开浏览器', + }, + weekOnWeek: { + title: '近一周浏览时长环比变化 TOP {k}', + lastBrowse: '上周浏览 {time}', + thisBrowse: '本周浏览 {time}', + wow: '环比{state} {delta}', + increase: '增长', + decline: '减少', + }, + feedback: { + button: '反馈', + tooltip: '告诉作者您对仪表盘新功能的感受~' + }, + }, + zh_TW: { + heatMap: { + title0: '近一年上網總時長超過 {hour} 小時', + title1: '近一年上網總時長不足 1 小時', + tooltip0: '{year}/{month}/{day} 瀏覽網頁 {minute} 分鐘', + tooltip1: '{year}/{month}/{day} 瀏覽網頁 {hour} 小時 {minute} 分鐘', + }, + topK: { + title: '近 {day} 天最常訪問 TOP {k}', + tooltip: '訪問 {host} {visit} 次', + }, + indicator: { + installedDays: '已使用 {number} 天', + visitCount: '訪問過 {site} 個網站總計 {visit} 次', + browsingTime: '瀏覽網頁超過 {minute} 分鐘', + mostUse: '最喜歡在 {start} 點至 {end} 點之間上網', + }, + weekOnWeek: { + title: '近一周瀏覽時長環比變化 TOP {k}', + lastBrowse: '上週瀏覽 {time}', + thisBrowse: '本週瀏覽 {time}', + wow: '環比{state} {delta}', + increase: '增長', + decline: '減少', + }, + feedback: EMPTY_FEEDBACK + }, + en: { + heatMap: { + title0: 'Browsed {hour}+ hours in the last year', + title1: 'Browsed for <1 hour in the last year', + tooltip0: 'Browsed for {minute} minute(s) on {month}/{day}/{year}', + tooltip1: 'Browsed for {hour} hour(s) {minute} minute(s) on {month}/{day}/{year}', + }, + topK: { + title: 'Most visited TOP {k} last {day} days', + tooltip: 'Visit {host} {visit} times', + }, + indicator: { + installedDays: '{number} days in use', + visitCount: '{visit} total visits to {site} sites', + browsingTime: 'Browsed for more than {minute} minutes', + mostUse: 'Favorite to browse between {start} and {end} o\'clock', + }, + weekOnWeek: { + title: 'Week-on-week change of browsing time TOP {k}', + lastBrowse: 'Browsed {time} last week', + thisBrowse: 'browsed {time} this week', + wow: 'week-on-week {state} {delta}', + increase: 'growth', + decline: 'decrease', + }, + feedback: EMPTY_FEEDBACK + }, + ja: { + heatMap: { + title0: '過去1年間に {hour} 時間以上オンラインで過ごした', + title1: '過去 1 年間にオンラインで費やした時間は 1 時間未満', + tooltip0: '{year} 年 {month} 月 {day} 日 {minute} 分間ウェブを閲覧する', + tooltip1: '{year} 年 {month} 月 {day} 日 ウェブを {hour} 時間 {minute} 分閲覧する', + }, + topK: { + title: '過去 {day} 日間に最も訪問された TOP {k}', + tooltip: '{host} {visit} 回訪問', + }, + indicator: { + installedDays: '使用 {number} 日', + visitCount: '{site} つのサイトへの合計 {visit} 回の訪問', + browsingTime: '{minute} 分以上ウェブを閲覧する', + mostUse: '{start}:00 から {end}:00 までのお気に入りのインターネットアクセス' + }, + weekOnWeek: { + title: '週ごとの変更 TOP {k}', + lastBrowse: '先週 {time} 閲覧', + thisBrowse: '今週は {time} で閲覧', + wow: '毎週 {delta} の {state}', + increase: '増加', + decline: '減らす', + }, + feedback: EMPTY_FEEDBACK + }, +} + +export default _default \ No newline at end of file diff --git a/src/app/locale/components/menu.ts b/src/app/locale/components/menu.ts index 6b63526b5..27cd768de 100644 --- a/src/app/locale/components/menu.ts +++ b/src/app/locale/components/menu.ts @@ -8,6 +8,7 @@ import { Messages } from "@util/i18n" export type MenuMessage = { + dashboard: string data: string dataReport: string dataHistory: string @@ -28,6 +29,7 @@ export type MenuMessage = { } const _default: Messages = { zh_CN: { + dashboard: '仪表盘', data: '我的数据', dataReport: '报表明细', dataHistory: '历史趋势', @@ -47,6 +49,7 @@ const _default: Messages = { translationMistake: '提交翻译错误' }, zh_TW: { + dashboard: '儀錶盤', data: '我的數據', dataReport: '報表明細', dataHistory: '曆史趨勢', @@ -66,6 +69,7 @@ const _default: Messages = { translationMistake: '改善翻译~' }, en: { + dashboard: "Dashboard", data: 'My Data', dataReport: 'Record', dataHistory: 'Trend', @@ -85,6 +89,7 @@ const _default: Messages = { translationMistake: 'Improve translation' }, ja: { + dashboard: 'ダッシュボード', data: '私のデータ', dataReport: '報告する', dataHistory: '歴史傾向', diff --git a/src/app/locale/messages.ts b/src/app/locale/messages.ts index 3bc16e45b..694c46706 100644 --- a/src/app/locale/messages.ts +++ b/src/app/locale/messages.ts @@ -19,6 +19,8 @@ import mergeRuleMessages, { MergeRuleMessage } from "./components/merge-rule" import siteManageManages, { SiteManageMessage } from "./components/site-manage" import operationMessages, { OperationMessage } from './components/operation' import confirmMessages, { ConfirmMessage } from './components/confirm' +import dashboardMessages, { DashboardMessage } from "./components/dashboard" +import calendarMessages, { CalendarMessage } from "@util/i18n/components/calendar" export type AppMessage = { dataManage: DataManageMessage @@ -34,6 +36,8 @@ export type AppMessage = { siteManage: SiteManageMessage operation: OperationMessage confirm: ConfirmMessage + dashboard: DashboardMessage + calendar: CalendarMessage } const _default: Messages = { @@ -50,7 +54,9 @@ const _default: Messages = { limit: limitMessages.zh_CN, siteManage: siteManageManages.zh_CN, operation: operationMessages.zh_CN, - confirm: confirmMessages.zh_CN + confirm: confirmMessages.zh_CN, + dashboard: dashboardMessages.zh_CN, + calendar: calendarMessages.zh_CN, }, zh_TW: { dataManage: dataManageMessages.zh_TW, @@ -65,7 +71,9 @@ const _default: Messages = { limit: limitMessages.zh_TW, siteManage: siteManageManages.zh_TW, operation: operationMessages.zh_TW, - confirm: confirmMessages.zh_TW + confirm: confirmMessages.zh_TW, + dashboard: dashboardMessages.zh_TW, + calendar: calendarMessages.zh_TW, }, en: { dataManage: dataManageMessages.en, @@ -80,7 +88,9 @@ const _default: Messages = { limit: limitMessages.en, siteManage: siteManageManages.en, operation: operationMessages.en, - confirm: confirmMessages.en + confirm: confirmMessages.en, + dashboard: dashboardMessages.en, + calendar: calendarMessages.en, }, ja: { dataManage: dataManageMessages.ja, @@ -95,7 +105,9 @@ const _default: Messages = { limit: limitMessages.ja, siteManage: siteManageManages.ja, operation: operationMessages.ja, - confirm: confirmMessages.ja + confirm: confirmMessages.ja, + dashboard: dashboardMessages.ja, + calendar: calendarMessages.ja, } } diff --git a/src/app/router/index.ts b/src/app/router/index.ts index b0216f4bb..9ebb79213 100644 --- a/src/app/router/index.ts +++ b/src/app/router/index.ts @@ -7,16 +7,19 @@ import { App } from "vue" import { createRouter, createWebHashHistory, RouteRecordRaw } from "vue-router" -import RouterDatabase from "@db/router-database" import { OPTION_ROUTE, TREND_ROUTE, LIMIT_ROUTE, REPORT_ROUTE } from "./constants" import metaService from "@service/meta-service" const dataRoutes: RouteRecordRaw[] = [ { path: '/data', - redirect: '/data/report', + redirect: '/data/dashboard', }, // Needn't nested router + { + path: '/data/dashboard', + component: () => import('../components/dashboard') + }, { path: REPORT_ROUTE, component: () => import('../components/report') @@ -73,16 +76,12 @@ const router = createRouter({ routes, }) -const db: RouterDatabase = new RouterDatabase(chrome.storage.local) - async function handleChange() { await router.isReady() const current = router.currentRoute.value.fullPath current && metaService.increaseApp(current) - current && await db.update(current) router.afterEach((to, from, failure: Error | void) => { if (failure || to.fullPath === from.fullPath) return - db.update(to.fullPath) metaService.increaseApp(to.fullPath) }) } diff --git a/src/app/styles/index.sass b/src/app/styles/index.sass index 8dc9c1053..8802ae7cd 100644 --- a/src/app/styles/index.sass +++ b/src/app/styles/index.sass @@ -95,9 +95,12 @@ a .container-card margin-top: 15px - .chart-container - width: 100% - height: 600px + min-height: 640px + .el-card__body + height: 100% + .chart-container + width: 100% + height: 600px .el-menu height: 100% diff --git a/src/background/uninstall-listener.ts b/src/background/uninstall-listener.ts index 2f31db46f..e49891aef 100644 --- a/src/background/uninstall-listener.ts +++ b/src/background/uninstall-listener.ts @@ -5,27 +5,13 @@ * https://opensource.org/licenses/MIT */ -import metaService from "@service/meta-service" import { UNINSTALL_QUESTIONNAIRE } from "@util/constant/url" import { locale } from "@util/i18n" -/** - * The percentage for gray - */ -const GRAY_PERCENTAGE = 20 - -function judgeGray(timeRand: Date) { - if (!timeRand) { - return false - } - return timeRand.getTime() % 100 < GRAY_PERCENTAGE -} - async function listen() { try { - const installTime = await metaService.getInstallTime() const uninstallUrl = UNINSTALL_QUESTIONNAIRE[locale] - uninstallUrl && judgeGray(installTime) && chrome.runtime.setUninstallURL(uninstallUrl) + uninstallUrl && chrome.runtime.setUninstallURL(uninstallUrl) } catch (e) { console.error(e) } diff --git a/src/database/icon-url-database.ts b/src/database/icon-url-database.ts index 3a09e1fda..928ff131d 100644 --- a/src/database/icon-url-database.ts +++ b/src/database/icon-url-database.ts @@ -51,7 +51,7 @@ class IconUrlDatabase extends BaseDatabase { const chromeEdgeIconUrlReg = /^(chrome|edge):\/\/favicon/ Object.entries(data) .filter(([key, value]) => key.startsWith(DB_KEY_PREFIX) && !!value && !items[key]) - .filter(([_key, value]) => !IS_FIREFOX || !chromeEdgeIconUrlReg.test(value as string)) + .filter(([_key, value]) => !chromeEdgeIconUrlReg.test(value as string)) .forEach(([key, value]) => toSave[key] = value) await this.storage.set(toSave) } diff --git a/src/database/period-database.ts b/src/database/period-database.ts index cbed21db3..b5c078c70 100644 --- a/src/database/period-database.ts +++ b/src/database/period-database.ts @@ -57,7 +57,6 @@ class PeriodDatabase extends BaseDatabase { const exists = await this.getBatch0(dates) merge(exists, items) this.updateBatch(exists) - return Promise.resolve() } private updateBatch(data: { [dateKey: string]: FocusPerDay }): Promise { @@ -76,6 +75,19 @@ class PeriodDatabase extends BaseDatabase { return db2PeriodInfos(await this.getBatch0(dates)) } + /** + * @since 1.0.0 + * @returns all period items + */ + async getAll(): Promise { + const allItems = await this.storage.get() + const periodItems: { [dateKey: string]: FocusPerDay } = {} + Object.entries(allItems) + .filter(([key]) => key.startsWith(KEY_PREFIX)) + .forEach(([key, val]) => periodItems[key] = val) + return db2PeriodInfos(periodItems) + } + async importData(data: any): Promise { const items = await this.storage.get() const keyReg = new RegExp(`${KEY_PREFIX}20\d{2}[01]\d[0-3]\d`) diff --git a/src/database/router-database.ts b/src/database/router-database.ts index 122947c72..90a696056 100644 --- a/src/database/router-database.ts +++ b/src/database/router-database.ts @@ -9,16 +9,29 @@ import BaseDatabase from "./common/base-database" import { REMAIN_WORD_PREFIX } from "./common/constant" const KEY = REMAIN_WORD_PREFIX + "app_router" + +/** + * @deprecated since 0.9.3 + */ class RouterDatabase extends BaseDatabase { + /** + * @deprecated since 0.9.3 + */ async getHistory(): Promise { const items = await this.storage.get(KEY) return items[KEY] } + /** + * @deprecated since 0.9.3 + */ update(newRoute: string): Promise { return this.setByKey(KEY, newRoute) } + /** + * @deprecated since 0.9.3 + */ async importData(_data: any): Promise { // Do nothing } diff --git a/src/popup/components/footer/all-function.ts b/src/popup/components/footer/all-function.ts index 166c6f6d5..39cf1ec21 100644 --- a/src/popup/components/footer/all-function.ts +++ b/src/popup/components/footer/all-function.ts @@ -4,16 +4,11 @@ * This software is released under the MIT License. * https://opensource.org/licenses/MIT */ - -import RouterDatabase from "@db/router-database" import { getAppPageUrl } from "@util/constant/url" import { t } from "@popup/locale" -const db: RouterDatabase = new RouterDatabase(chrome.storage.local) - const allFunctionLink = document.getElementById('all-function-link') allFunctionLink.onclick = async () => { - const historyRoute = await db.getHistory() - chrome.tabs.create({ url: getAppPageUrl(false, historyRoute || '/') }) + chrome.tabs.create({ url: getAppPageUrl(false, '/') }) } allFunctionLink.innerText = t(msg => msg.viewMore) \ No newline at end of file diff --git a/src/service/components/immigration.ts b/src/service/components/immigration.ts index 3a0b1b55c..e6e7ec942 100644 --- a/src/service/components/immigration.ts +++ b/src/service/components/immigration.ts @@ -13,7 +13,6 @@ import IconUrlDatabase from "@db/icon-url-database" import LimitDatabase from "@db/limit-database" import MergeRuleDatabase from "@db/merge-rule-database" import PeriodDatabase from "@db/period-database" -import RouterDatabase from "@db/router-database" import TimerDatabase from "@db/timer-database" import WhitelistDatabase from "@db/whitelist-database" import HostAliasDatabase from "@db/host-alias-database" @@ -36,7 +35,6 @@ function initDatabase(storage: chrome.storage.StorageArea): BaseDatabase[] { new LimitDatabase(storage), new MergeRuleDatabase(storage), new WhitelistDatabase(storage), - new RouterDatabase(storage), new HostAliasDatabase(storage) ] diff --git a/src/service/period-service.ts b/src/service/period-service.ts index f5fad3d90..760881f4f 100644 --- a/src/service/period-service.ts +++ b/src/service/period-service.ts @@ -60,17 +60,17 @@ async function list(inParam?: PeriodQueryParam): Promise { const originData: PeriodInfo[] = await periodDatabase.getBatch(allDates) const windowSize = param.periodSize - if (windowSize <= 0) return Promise.resolve([]) + if (windowSize <= 0) return [] const maxPeriod = getMaxDivisiblePeriod(end, windowSize) // Get ride of the latest ones can't be merged into one window const realData = originData.filter(data => data.compare(maxPeriod) <= 0 && data.compare(start) >= 0) const mergeConfig = { start, end: maxPeriod, windowSize } - return Promise.resolve(merge(realData, mergeConfig)) + return merge(realData, mergeConfig) } class PeriodService { - public add = add - public list = list + add = add + list = list } export default new PeriodService() diff --git a/src/service/timer-service.ts b/src/service/timer-service.ts index 0f3e04bf6..c5f224c81 100644 --- a/src/service/timer-service.ts +++ b/src/service/timer-service.ts @@ -43,7 +43,7 @@ export type TimerQueryParam = TimerCondition & { /** * The name of sorted column */ - sort?: string + sort?: keyof DataItem /** * 1 asc, -1 desc */ @@ -113,7 +113,7 @@ class TimerService { .filter(host => host.includes(fuzzyQuery)) .forEach(host => merged.add(host)) - return Promise.resolve({ origin, merged }) + return { origin, merged } } /** @@ -196,7 +196,7 @@ class TimerService { await this.fillIconUrl(list) await this.fillAlias(list) } - return Promise.resolve(result) + return result } private filter(origin: DataItem[], param: TimerCondition) { diff --git a/src/util/array.ts b/src/util/array.ts new file mode 100644 index 000000000..cf523ddbd --- /dev/null +++ b/src/util/array.ts @@ -0,0 +1,62 @@ +/** + * Copyright (c) 2022 Hengyang Zhang + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ +/** + * Group by + * + * @param arr original array + * @param keyFunc key generator + * @returns grouped map + * @since 1.0.0 + */ +export function groupBy( + arr: T[], + keyFunc: (e: T) => string | number, + downstream: (grouped: T[]) => R +): { [key: string]: R } { + const groupedMap: { [key: string]: T[] } = {} + arr.forEach(e => { + const key = keyFunc(e) + const existArr: T[] = groupedMap[key] || [] + existArr.push(e) + groupedMap[key] = existArr + }) + const result = {} + Object.entries(groupedMap) + .forEach(([key, grouped]) => result[key] = downstream(grouped)) + return result +} + +/** + * Rotate the array without new one returned + * + * @param arr the targe array + * @param count count to rotate, must be positive, or 1 is default + * @param leftOrRight rotate right or left, true means left, false means right, default is false + */ +export function rotate(arr: T[], count?: number, rightOrLeft?: boolean): void { + let realTime = 1 + if (count && count > 1) { + realTime = count + } + const operation = !!rightOrLeft + // Right + ? (a: T[]) => a.unshift(a.pop()) + // Left + : (a: T[]) => a.push(a.shift()) + for (; realTime > 0; realTime--) { + operation(arr) + } +} + +/** + * Summarize + * + * @param arr target arr + */ +export function sum(arr: number[]): number { + return arr?.reduce?.((a, b) => (a || 0) + (b || 0), 0) || 0 +} \ No newline at end of file diff --git a/src/util/constant/url.ts b/src/util/constant/url.ts index fb1843e9f..c67e7c76d 100644 --- a/src/util/constant/url.ts +++ b/src/util/constant/url.ts @@ -117,4 +117,9 @@ export const PSL_HOMEPAGE = 'https://publicsuffix.org/' /** * @since 0.9.3 */ -export const TRANSLATION_ISSUE_PAGE = 'https://docs.google.com/forms/d/e/1FAIpQLSdZSmEZp6Xfmb5v-3H4hsubgeCReDayDOuWDWWU5C1W80exGA/viewform?usp=sf_link' \ No newline at end of file +export const TRANSLATION_ISSUE_PAGE = 'https://docs.google.com/forms/d/e/1FAIpQLSdZSmEZp6Xfmb5v-3H4hsubgeCReDayDOuWDWWU5C1W80exGA/viewform?usp=sf_link' + +/** + * @since 1.0.0 + */ +export const DASHBOARD_FEEDBACK_PAGE = 'https://www.wjx.cn/vm/wn0tj2s.aspx' \ No newline at end of file diff --git a/src/util/i18n/components/calendar.ts b/src/util/i18n/components/calendar.ts new file mode 100644 index 000000000..a37665396 --- /dev/null +++ b/src/util/i18n/components/calendar.ts @@ -0,0 +1,39 @@ +/** + * Copyright (c) 2022 Hengyang Zhang + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +import { Messages } from ".." + +export type CalendarMessage = { + weekDays: string + months: string + dateFormat: string +} + +const _default: Messages = { + zh_CN: { + weekDays: '星期一|星期二|星期三|星期四|星期五|星期六|星期天', + months: '一月|二月|三月|四月|五月|六月|七月|八月|九月|十月|十一月|十二月', + dateFormat: '{y}/{m}/{d}', + }, + zh_TW: { + weekDays: '禮拜一|禮拜二|禮拜三|禮拜四|禮拜五|禮拜六|禮拜天', + months: '一月|二月|三月|四月|五月|六月|七月|八月|九月|十月|十一月|十二月', + dateFormat: '{y}/{m}/{d}', + }, + en: { + weekDays: 'Mon|Tue|Wed|Thu|Fri|Sat|Sun', + months: 'Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Set|Oct|Nov|Dec', + dateFormat: '{m}/{d}/{y}' + }, + ja: { + weekDays: 'Mon|Tue|Wed|Thu|Fri|Sat|Sun', + months: 'Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Set|Oct|Nov|Dec', + dateFormat: '{y}/{m}/{d}' + }, +} + +export default _default \ No newline at end of file diff --git a/src/util/time.ts b/src/util/time.ts index 401780b15..c0fce47f4 100644 --- a/src/util/time.ts +++ b/src/util/time.ts @@ -120,28 +120,62 @@ export function isSameDay(a: Date, b: Date): boolean { */ export function getWeekTime(now: Date, isChinese: boolean): [Date, Date] { const date = new Date(now) - const nowWeekday = isChinese - // Trans 2 chinese weekday - ? (date.getDay() + 6) % 7 - : date.getDay() + const nowWeekday = getWeekDay(date, isChinese) const startTime = new Date(date.getFullYear(), date.getMonth(), date.getDate() - nowWeekday) return [new Date(startTime), now] } +/** + * Get the start time {@param weekCount} weeks ago + * + * @param now the specific time + * @param weekCount weekCount + * @since 1.0.0 + */ +export function getWeeksAgo(now: Date, isChinese: boolean, weekCount: number): Date { + const date = new Date(now) + const nowWeekday = getWeekDay(date, isChinese) + return new Date(date.getFullYear(), date.getMonth(), date.getDate() - nowWeekday - weekCount * 7) +} + +/** + * @returns 0 to 6, means Monday to Sunday if Chinese, or Sunday to Saturday + */ +function getWeekDay(now: Date, isChinese: boolean): number { + const date = new Date(now) + return isChinese + // Trans 2 chinese weekday + ? (date.getDay() + 6) % 7 + : date.getDay() +} /** * Get the start time and end time of this month * - * @param now the specific time + * @param target the specific time * @returns [startTime, endTime] * * @since 0.6.0 */ -export function getMonthTime(now: Date): [Date, Date] { - const currentMonth = now.getMonth() - const currentYear = now.getFullYear() +export function getMonthTime(target: Date): [Date, Date] { + const currentMonth = target.getMonth() + const currentYear = target.getFullYear() const start = new Date(currentYear, currentMonth, 1) const endTime = new Date(currentYear, currentMonth + 1, 1).getTime() const end = new Date(endTime - 1) return [start, end] +} + +/** + * Get the start time of this day + * + * @param target the specific time + * @returns the start of this day + * @since 1.0.0 + */ +export function getStartOfDay(target: Date) { + const currentMonth = target.getMonth() + const currentYear = target.getFullYear() + const currentDate = target.getDate() + return new Date(currentYear, currentMonth, currentDate) } \ No newline at end of file diff --git a/test/__mock__/storage.ts b/test/__mock__/storage.ts index e4cf336c9..87300938f 100644 --- a/test/__mock__/storage.ts +++ b/test/__mock__/storage.ts @@ -41,22 +41,22 @@ const sync = { result = resolveKey(id) cb = args[1] } - cb && cb(result) + cb?.(result) }), getBytesInUse: jest.fn(cb => cb && cb(0)), set: jest.fn((payload, cb) => { Object.keys(payload).forEach((key) => (store[key] = payload[key])) - cb && cb() + cb?.() }), remove: jest.fn((id, cb) => { const idType = typeof id const keys: string[] = idType === 'string' ? [id] : (Array.isArray(id) ? id : Object.keys(id)) keys.forEach((key: string) => delete store[key]) - cb && cb() + cb?.() }), - clear: jest.fn((cb) => { + clear: jest.fn(cb => { store = {} - cb && cb() + cb?.() }) } as unknown as chrome.storage.SyncStorageArea diff --git a/test/common/logger.test.ts b/test/common/logger.test.ts new file mode 100644 index 000000000..0a1ed2241 --- /dev/null +++ b/test/common/logger.test.ts @@ -0,0 +1,22 @@ +/** + * Copyright (c) 2022 Hengyang Zhang + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +import { openLog, log, closeLog } from "@src/common/logger" + +test('test open log', () => { + global.console.log = jest.fn() + openLog() + log("foobar") + expect(console.log).toBeCalledWith("foobar") +}) + +test('test close log', () => { + global.console.log = jest.fn() + closeLog() + log("foobar") + expect(console.log).toBeCalledTimes(0) +}) \ No newline at end of file diff --git a/test/database/icon-url-database.test.ts b/test/database/icon-url-database.test.ts index 96d9fa66b..21ffa17c8 100644 --- a/test/database/icon-url-database.test.ts +++ b/test/database/icon-url-database.test.ts @@ -8,6 +8,7 @@ const baidu = 'baidu.com' describe('icon-url-database', () => { beforeEach(async () => { await storage.local.clear() + // Mock Chrome const mockUserAgent = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.141 Safari/537.36" Object.defineProperty(global.navigator, 'userAgent', { value: mockUserAgent, configurable: true }) }) @@ -20,4 +21,27 @@ describe('icon-url-database', () => { let foo = 'baidu123213131' expect((await db.get(foo))[foo]).toBeUndefined() }) + + test("import data", async () => { + await db.put(baidu, "test1") + const data2Import = { + "__timer__ICON_URLbaidu.com": "test0", + "__timer__ICON_URLwww.qq.com": "test2", + // Invalid icon url + "_timer__ICON_URLwww.qq.com": "1111", + // Not import + "__timer__ICON_URLgoogle.com": "chrome://favicon/google.com" + } + await db.importData(data2Import) + const items = await db.storage.get() + expect(Object.values(items).length).toEqual(2) + // Not overwrite + const baiduIconUrl = (await db.get(baidu))[baidu] + expect(baiduIconUrl).toEqual('test1') + const qqIconUrl = (await db.get("www.qq.com"))["www.qq.com"] + expect(qqIconUrl).toEqual('test2') + // Not import + const googleIconUrl = (await db.get("google.com"))["google.com"] + expect(googleIconUrl).toBeUndefined() + }) }) \ No newline at end of file diff --git a/test/database/limit-database.test.ts b/test/database/limit-database.test.ts index 8e24df7fb..78a7d3747 100644 --- a/test/database/limit-database.test.ts +++ b/test/database/limit-database.test.ts @@ -1,5 +1,5 @@ import LimitDatabase from "@db/limit-database" -import { TimeLimit } from "@entity/dao/time-limit" +import type { TimeLimit, TimeLimitInfo } from "@entity/dao/time-limit" import storage from "../__mock__/storage" const db = new LimitDatabase(storage.local) @@ -32,4 +32,102 @@ describe('archived-database', () => { expect((await db.all()).length).toEqual(0) }) + + test("update waste", async () => { + await db.save({ + cond: "a.*.com", + time: 21, + enabled: true, + allowDelay: false, + }) + await db.save({ + cond: "*.b.com", + time: 20, + enabled: true, + allowDelay: false, + }) + await db.updateWaste("20220606", { + "a.*.com": 10, + // Not exist, no error throws + "foobar": 20, + }) + const all = await db.all() + const used = all.find(a => a.cond === "a.*.com") + expect(used?.latestDate).toEqual("20220606") + expect(used?.wasteTime).toEqual(10) + }) + + test("import data", async () => { + const cond1: TimeLimit = { + cond: "cond1", + time: 20, + allowDelay: false, + enabled: true + } + const cond2: TimeLimit = { + cond: "cond2", + time: 20, + allowDelay: false, + enabled: false + } + await db.save(cond1) + await db.save(cond2) + const data2Import = await db.storage.get() + // Set new empty + data2Import["__timer__LIMIT"]["cond3"] = {} + + // clear + storage.local.clear() + expect(await db.all()).toEqual([]) + + // cond1 exists + await db.save({ ...cond1, enabled: false }) + await db.updateWaste("20220606", { "cond1": 10 }) + + await db.importData(data2Import) + const imported = await db.all() + // Exists + const cond1After = imported.find(a => a.cond === "cond1") + expect(cond1After?.latestDate).toEqual("20220606") + expect(cond1After?.wasteTime).toEqual(10) + expect(cond1After?.enabled).toBeFalsy() + // Not exists + const cond2After = imported.find(a => a.cond === "cond2") + expect(!!cond2After?.latestDate).toBeFalsy() + expect(!!cond2After?.wasteTime).toBeFalsy() + expect(cond2After?.allowDelay).toEqual(cond2.allowDelay) + expect(cond2After?.enabled).toEqual(cond2.enabled) + // Not complete + const cond3After = imported.find(a => a.cond === "cond3") + expect(cond3After.time).toEqual(0) + expect(cond3After.enabled).toBeFalsy() + expect(cond3After.allowDelay).toBeFalsy() + }) + + test("import data2", async () => { + const importData = {} + // Invalid data, no error throws + await db.importData(importData) + // Valid data + importData["__timer__LIMIT"] = {} + await db.importData(importData) + expect(await db.all()).toEqual([]) + }) + + test("update delay", async () => { + const data: TimeLimit = { + cond: "cond1", + time: 20, + allowDelay: false, + enabled: true + } + await db.save(data) + await db.updateDelay("cond1", true) + await db.updateDelay("cond2", true) + const all: TimeLimitInfo[] = await db.all() + expect(all.length).toEqual(1) + const item = all[0] + expect(item.allowDelay).toBeTruthy() + expect(item.cond).toEqual("cond1") + }) }) \ No newline at end of file diff --git a/test/util/array.test.ts b/test/util/array.test.ts new file mode 100644 index 000000000..b25e3006b --- /dev/null +++ b/test/util/array.test.ts @@ -0,0 +1,40 @@ +/** + * Copyright (c) 2022 Hengyang Zhang + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +import { groupBy, rotate } from "@util/array" + +test('group by', () => { + const arr = [ + [1, 2], + [1, 3], + [2, 3], + [3, 4], + [2, 9] + ] + // Find the max value of each group + const maxMap = groupBy(arr, a => a[0], arr => Math.max(...arr.map(a => a[1]))) + expect(maxMap).toEqual({ + 1: 3, + 2: 9, + 3: 4 + }) + const allVal = groupBy(arr, _ => undefined, arr => arr.map(a => a[1])) + expect(allVal).toEqual({ undefined: [2, 3, 3, 4, 9] }) +}) + +test("rotate", () => { + const arr = [1, 2, 3, 4, 5, 6] + // Left rotate for 1 time + rotate(arr) + expect(arr).toEqual([2, 3, 4, 5, 6, 1]) + // Left rotate again for 2 times + rotate(arr, 2, false) + expect(arr).toEqual([4, 5, 6, 1, 2, 3]) + // Right rotate for 3 times + rotate(arr, 3, true) + expect(arr).toEqual([1, 2, 3, 4, 5, 6]) +}) \ No newline at end of file diff --git a/test/util/time.test.ts b/test/util/time.test.ts index c1c8f57e4..6cb721f65 100644 --- a/test/util/time.test.ts +++ b/test/util/time.test.ts @@ -1,4 +1,4 @@ -import { daysAgo, formatPeriod, formatPeriodCommon, formatTime } from "@util/time" +import { daysAgo, formatPeriod, formatPeriodCommon, formatTime, getMonthTime, getStartOfDay, getWeeksAgo, getWeekTime, isSameDay } from "@util/time" test('time', () => { const dateStr = '2020/05/01 00:00:01' @@ -22,7 +22,7 @@ test('time', () => { expect(formatTime(new Date(date), format)).toEqual(result) }) -test('time', () => { +test('format', () => { const msg = { hourMsg: '{hour}时{minute}分{second}秒', minuteMsg: '{minute}分{second}秒', @@ -33,8 +33,97 @@ test('time', () => { expect(formatPeriodCommon(1)).toEqual('0 s') }) -test('time', () => { +test('days ago', () => { const start = Math.floor(Math.random() * 100) const range = daysAgo(start + 2, start) expect(range[1].getTime() - range[0].getTime()).toEqual(1000/*ms/s*/ * 60/*s/min*/ * 60/*min/h*/ * 24/*h/day*/ * 2/*day*/) +}) + +test("weeks ago", () => { + // 2022/06/01, Thu + let now = new Date(2022, 5, 1) + // 2022/05/23 Mon. + const weekAgoChinese = getWeeksAgo(now, true, 1) + expect(weekAgoChinese.getMonth()).toEqual(4) + expect(weekAgoChinese.getDate()).toEqual(23) + expect(weekAgoChinese.getDay()).toEqual(1) + expect(weekAgoChinese.getHours()).toEqual(0) + expect(weekAgoChinese.getMinutes()).toEqual(0) + expect(weekAgoChinese.getSeconds()).toEqual(0) + expect(weekAgoChinese.getMilliseconds()).toEqual(0) + // 2022/05/22 Sun. + const weekAgoNoChinese = getWeeksAgo(now, false, 1) + expect(weekAgoNoChinese.getMonth()).toEqual(4) + expect(weekAgoNoChinese.getDate()).toEqual(22) + expect(weekAgoNoChinese.getDay()).toEqual(0) + expect(weekAgoNoChinese.getHours()).toEqual(0) + expect(weekAgoNoChinese.getMinutes()).toEqual(0) + expect(weekAgoNoChinese.getSeconds()).toEqual(0) + expect(weekAgoNoChinese.getMilliseconds()).toEqual(0) + // If now is 2022/05/22 Sun. + now = new Date(2022, 4, 22) + expect(getWeeksAgo(now, true, 1).getDate()).toEqual(9) + expect(getWeeksAgo(now, false, 1).getDate()).toEqual(15) + // If now is 2022/05/23 Mon. + now = new Date(2022, 4, 23) + expect(getWeeksAgo(now, true, 1).getDate()).toEqual(16) + expect(getWeeksAgo(now, false, 1).getDate()).toEqual(15) +}) + +test("is same day", () => { + const date1 = new Date(2022, 4, 11) + date1.setHours(23) + const date2 = new Date(2022, 4, 11) + date2.setHours(10) + expect(isSameDay(date1, date2)).toBeTruthy() + date1.setHours(25) + expect(isSameDay(date1, date2)).toBeFalsy() +}) + +test("get week time", () => { + // now 2022/05/22, Sun. + const now = new Date(2022, 4, 22) + // [2022/05/16, 2022/05/22] + let [s1, e1] = getWeekTime(now, true) + expect(s1.getDate()).toEqual(16) + expect(s1.getHours()).toEqual(0) + expect(s1.getMinutes()).toEqual(0) + expect(s1.getSeconds()).toEqual(0) + expect(s1.getMilliseconds()).toEqual(0) + expect(e1).toEqual(now) + // [2022/05/22, 2022/05/28] + let [s2] = getWeekTime(now, false) + expect(s2.getDate()).toEqual(22) +}) + +test("get month time", () => { + // 2022/05/02 + const now = new Date(2022, 4, 2) + const [start, end] = getMonthTime(now) + expect(start.getMonth()).toEqual(4) + expect(start.getDate()).toEqual(1) + expect(start.getHours()).toEqual(0) + expect(start.getMinutes()).toEqual(0) + expect(start.getSeconds()).toEqual(0) + expect(start.getMilliseconds()).toEqual(0) + + expect(end.getMonth()).toEqual(4) + expect(end.getDate()).toEqual(31) + expect(end.getHours()).toEqual(23) + expect(end.getMinutes()).toEqual(59) + expect(end.getSeconds()).toEqual(59) + expect(end.getMilliseconds()).toEqual(999) +}) + +test("get start of day", () => { + // 2022/05/22 11:30:29 + const now = new Date(2022, 4, 2) + now.setHours(11, 30, 29, 999) + const start = getStartOfDay(now) + expect(start.getMonth()).toEqual(4) + expect(start.getDate()).toEqual(2) + expect(start.getHours()).toEqual(0) + expect(start.getMinutes()).toEqual(0) + expect(start.getSeconds()).toEqual(0) + expect(start.getMilliseconds()).toEqual(0) }) \ No newline at end of file