Skip to content

Commit 5bf0c63

Browse files
authored
feat: support tracking timeline (#538)
1 parent fa4c362 commit 5bf0c63

File tree

47 files changed

+1040
-67
lines changed

Some content is hidden

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

47 files changed

+1040
-67
lines changed

package.json

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
"description": "Time tracker for browser",
55
"homepage": "https://www.wfhg.cc",
66
"scripts": {
7+
"pure-install": "npm install --include=optional --ignore-scripts",
78
"dev": "rspack --config=rspack/rspack.dev.ts --watch",
89
"dev:firefox": "rspack --config=rspack/rspack.dev.firefox.ts --watch",
910
"dev:safari": "rspack --config=rspack/rspack.dev.safari.ts --watch",
@@ -34,7 +35,7 @@
3435
"@rsdoctor/rspack-plugin": "^1.2.3",
3536
"@rspack/cli": "^1.4.11",
3637
"@rspack/core": "^1.4.11",
37-
"@swc/core": "^1.13.3",
38+
"@swc/core": "^1.13.5",
3839
"@swc/jest": "^0.2.39",
3940
"@types/chrome": "0.1.4",
4041
"@types/decompress": "^4.2.7",
@@ -57,7 +58,7 @@
5758
"sass": "^1.90.0",
5859
"sass-loader": "^16.0.5",
5960
"style-loader": "^4.0.0",
60-
"ts-loader": "^9.5.2",
61+
"ts-loader": "^9.5.4",
6162
"ts-node": "^10.9.2",
6263
"tsconfig-paths": "^4.2.0",
6364
"typescript": "5.9.2",
@@ -71,7 +72,7 @@
7172
"@vueuse/core": "^13.7.0",
7273
"countup.js": "^2.9.0",
7374
"echarts": "^6.0.0",
74-
"element-plus": "2.10.7",
75+
"element-plus": "2.11.1",
7576
"js-base64": "^3.7.8",
7677
"punycode": "^2.3.1",
7778
"stream-browserify": "^3.0.0",
@@ -81,4 +82,4 @@
8182
"engines": {
8283
"node": ">=20"
8384
}
84-
}
85+
}

script/crowdin/sync-source.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { type SourceFilesModel, type SourceStringsModel } from "@crowdin/crowdin-api-client"
2-
import { groupBy } from "@util/array"
2+
import { toMap } from "@util/array"
33
import { type CrowdinClient, getClientFromEnv, type NameKey } from "./client"
44
import {
55
ALL_DIRS,
@@ -33,7 +33,7 @@ async function processStrings(
3333
fileContent: ItemSet,
3434
) {
3535
const existStrings = await client.listStringsByFile(existFile.id)
36-
const existStringsKeyMap = groupBy(existStrings, s => s.identifier, l => l[0])
36+
const existStringsKeyMap = toMap(existStrings, s => s.identifier)
3737
const strings2Delete: SourceStringsModel.String[] = []
3838
const strings2Create: ItemSet = {}
3939
const strings2Update: ItemSet = {}
@@ -70,7 +70,7 @@ async function processByDir(client: CrowdinClient, dir: Dir, branch: SourceFiles
7070
// 3. list all files in directory
7171
const existFiles = await client.listFilesByDirectory(directory.id)
7272
console.log("Exists file count: " + existFiles.length)
73-
const existFileNameMap = groupBy(existFiles, f => f.name, l => l[0])
73+
const existFileNameMap = toMap(existFiles, f => f.name)
7474
// 4. create new files
7575
for (const [fileName, msg] of Object.entries(messages)) {
7676
if (isIgnored(dir, fileName)) {

script/crowdin/sync-translation.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11

22
import { type SourceFilesModel } from "@crowdin/crowdin-api-client"
3-
import { groupBy } from "@util/array"
3+
import { toMap } from "@util/array"
44
import { exitWith } from "../util/process"
55
import { type CrowdinClient, getClientFromEnv } from "./client"
66
import {
@@ -14,7 +14,7 @@ const CROWDIN_USER_ID_OF_OWNER = 15266594
1414
async function processDirMessage(client: CrowdinClient, file: SourceFilesModel.File, message: ItemSet, lang: CrowdinLanguage): Promise<void> {
1515
console.log(`Start to process dir message: fileName=${file.name}, lang=${lang}`)
1616
const strings = await client.listStringsByFile(file.id)
17-
const stringMap = groupBy(strings, s => s.identifier, l => l[0])
17+
const stringMap = toMap(strings, s => s.identifier)
1818
for (const [identifier, text] of Object.entries(message)) {
1919
const string = stringMap[identifier]
2020
if (!string) {
@@ -52,7 +52,7 @@ async function processDir(client: CrowdinClient, dir: Dir, branch: SourceFilesMo
5252
}
5353
const files = await client.listFilesByDirectory(directory!.id)
5454
console.log(`find ${files.length} files of ${dir}`)
55-
const fileMap = groupBy(files, f => f.name, l => l[0])
55+
const fileMap = toMap(files, f => f.name)
5656
for (const [fileName, message] of Object.entries(messages)) {
5757
console.log(`Start to sync translations of ${dir}/${fileName}`)
5858
if (isIgnored(dir, fileName)) {

src/background/content-script-handler.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import optionHolder from "@service/components/option-holder"
1212
import whitelistHolder from "@service/components/whitelist-holder"
1313
import limitService from "@service/limit-service"
1414
import siteService from "@service/site-service"
15+
import { saveTimelineEvent } from '@service/timeline-service'
1516
import { getAppPageUrl } from "@util/constant/url"
1617
import { extractFileHost, extractHostname } from "@util/pattern"
1718
import badgeManager from "./badge-manager"
@@ -73,4 +74,5 @@ export default function init(dispatcher: MessageDispatcher) {
7374
const exist = await siteService.get(site)
7475
return exist?.run ? site : null
7576
})
77+
.register<timer.timeline.Event, void>('cs.timelineEv', ev => saveTimelineEvent(ev))
7678
}

src/background/track-server.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,7 @@ async function handleIncVisitEvent(param: { host: string, url: string }, sender:
9898
function splitRunTime(start: number, end: number): Record<string, number> {
9999
const res: Record<string, number> = {}
100100
while (start < end) {
101-
const startOfNextDay = getStartOfDay(new Date(start)).getTime() + MILL_PER_DAY
101+
const startOfNextDay = getStartOfDay(start).getTime() + MILL_PER_DAY
102102
const newStart = Math.min(end, startOfNextDay)
103103
const runTime = newStart - start
104104
runTime && (res[formatTimeYMD(start)] = runTime)

src/content-script/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { sendMsg2Runtime } from "@api/chrome/runtime"
99
import { initLocale } from "@i18n"
1010
import processLimit from "./limit"
1111
import printInfo from "./printer"
12+
import processTimeline from './timeline'
1213
import NormalTracker from "./tracker/normal"
1314
import RunTimeTracker from "./tracker/run-time"
1415

@@ -73,6 +74,8 @@ async function main() {
7374
!!needPrintInfo && printInfo(host)
7475
await processLimit(url)
7576

77+
processTimeline()
78+
7679
// Increase visit count at the end
7780
await sendMsg2Runtime('cs.incVisitCount', { host, url })
7881
}

src/content-script/timeline.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import { sendMsg2Runtime } from '@api/chrome/runtime'
2+
3+
class TimelineCollector {
4+
private startTime: number | null = null
5+
6+
/**
7+
* Bind page visibility and focus events
8+
*/
9+
init(): void {
10+
document.addEventListener('visibilitychange', () => {
11+
if (document.hidden) {
12+
this.collect()
13+
} else {
14+
this.startTracking()
15+
}
16+
})
17+
18+
window.addEventListener('focus', () => this.startTracking())
19+
window.addEventListener('blur', () => this.collect())
20+
window.addEventListener('beforeunload', () => this.collect())
21+
22+
if (document.readyState === 'complete') {
23+
this.startTracking()
24+
} else {
25+
window.addEventListener('load', () => this.startTracking())
26+
}
27+
}
28+
29+
/**
30+
* Start tracking current page
31+
*/
32+
public startTracking(): void {
33+
this.startTime = Date.now()
34+
}
35+
36+
/**
37+
* End current session and generate event
38+
*/
39+
private collect(): void {
40+
if (!this.startTime) return
41+
const url = document?.location?.href
42+
43+
url && sendMsg2Runtime('cs.timelineEv', {
44+
start: this.startTime,
45+
end: Date.now(),
46+
url,
47+
} satisfies timer.timeline.Event)
48+
49+
this.startTime = null
50+
}
51+
}
52+
53+
54+
export default function processTimeline() {
55+
const collector = new TimelineCollector()
56+
collector.init()
57+
}

src/database/site-database.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,7 @@ function cvt2SiteInfo(key: timer.site.SiteKey, entry: _Entry | undefined): timer
8585
const { a, i, c, r } = entry || {}
8686
const siteInfo: timer.site.SiteInfo = { ...key }
8787
siteInfo.alias = a
88-
siteInfo.cate = c
88+
siteInfo.cate = c ?? CATE_NOT_SET_ID
8989
siteInfo.iconUrl = i
9090
siteInfo.run = !!r
9191
return siteInfo

src/database/timeline-database.ts

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import { formatTimeYMD, MILL_PER_DAY } from '@util/time'
2+
import BaseDatabase from './common/base-database'
3+
import { REMAIN_WORD_PREFIX } from './common/constant'
4+
5+
const DB_KEY = REMAIN_WORD_PREFIX + 'TL'
6+
7+
type Item = {
8+
// start
9+
s: number
10+
// duration
11+
d: number
12+
}
13+
14+
type TimelineData = {
15+
[date: string]: {
16+
[host: string]: Item[]
17+
}
18+
}
19+
20+
// If two tick with the same host is near 1 sec, then merge them to one
21+
const MERGE_THRESHOLD = 1000
22+
23+
const canMerge = (item: Item, tick: timer.timeline.Tick) => {
24+
const { s: is, d: id } = item
25+
const { start } = tick
26+
return start >= is + id
27+
&& start <= id + MERGE_THRESHOLD
28+
}
29+
30+
const isConflict = (item: Item, tick: timer.timeline.Tick) => {
31+
const { s: is, d: id } = item
32+
const { start } = tick
33+
return is <= start && start < is + id
34+
}
35+
36+
const merge = (data: TimelineData, tick: timer.timeline.Tick) => {
37+
const { start, duration, host } = tick
38+
const date = formatTimeYMD(start)
39+
const hostData = data[date] ?? {}
40+
const items = hostData[host] ?? []
41+
items.sort((a, b) => (a?.s ?? 0) - (b?.s ?? 0))
42+
for (const item of items) {
43+
if (isConflict(item, tick)) {
44+
return
45+
}
46+
if (canMerge(item, tick)) {
47+
item.d = start + duration - item.s
48+
return
49+
}
50+
}
51+
// normal tick
52+
items.push({ s: start, d: duration })
53+
hostData[host] = items
54+
data[date] = hostData
55+
}
56+
57+
export const TIMELINE_LIFE_CYCLE = 3
58+
59+
const removeOutdated = (data: TimelineData, currTime: number) => {
60+
const minDate = formatTimeYMD(currTime - MILL_PER_DAY * (TIMELINE_LIFE_CYCLE - 1))
61+
const keys = Object.keys(data).filter(k => k < minDate)
62+
keys.forEach(key => delete data[key])
63+
}
64+
65+
class TimelineDatabase extends BaseDatabase {
66+
private async getData(): Promise<TimelineData> {
67+
const data = await this.storage.getOne<TimelineData>(DB_KEY)
68+
return data ?? {}
69+
}
70+
71+
private setData(data: TimelineData): Promise<void> {
72+
return this.setByKey(DB_KEY, data)
73+
}
74+
75+
async batchSave(ticks: timer.timeline.Tick[]) {
76+
const data = await this.getData()
77+
ticks.forEach(tick => {
78+
merge(data, tick)
79+
removeOutdated(data, tick.start)
80+
})
81+
await this.setData(data)
82+
}
83+
84+
async getAll(): Promise<timer.timeline.Tick[]> {
85+
const data = await this.getData()
86+
const result: timer.timeline.Tick[] = []
87+
Object.values(data).forEach(hostData => {
88+
Object.entries(hostData).forEach(([host, items]) => {
89+
items.forEach(({ s: start, d: duration }) => result.push({ host, start, duration }))
90+
})
91+
})
92+
return result
93+
}
94+
95+
async importData(_: any): Promise<void> {
96+
// do nothing
97+
}
98+
}
99+
const timelineDatabase = new TimelineDatabase()
100+
101+
export default timelineDatabase

src/i18n/message/app/dashboard-resource.json

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,13 @@
1515
},
1616
"monthOnMonth": {
1717
"title": "浏览时间环比趋势"
18+
},
19+
"timeline": {
20+
"title": "近 {n} 天时间线",
21+
"busyScore": "忙碌指数",
22+
"busyScoreDesc": "与每小时浏览的总时长和网站数量有关,详细计算公式请查看源代码",
23+
"focusScore": "专注指数",
24+
"focusScoreDesc": "与同一网站连续浏览总时长有关,详细计算公式请查看源代码"
1825
}
1926
},
2027
"zh_TW": {
@@ -51,6 +58,13 @@
5158
},
5259
"monthOnMonth": {
5360
"title": "Browsing time month-on-month trend"
61+
},
62+
"timeline": {
63+
"title": "Timeline of the recent {n} days",
64+
"busyScore": "Busy Score",
65+
"busyScoreDesc": "Related to the total browsing time and the number of websites per hour. See the source code for calculation formula",
66+
"focusScore": "Focus Score",
67+
"focusScoreDesc": "Related to the total time of continuous browsing of the same website. See the source code for calculation formula"
5468
}
5569
},
5670
"ja": {

0 commit comments

Comments
 (0)