Initial commit
This commit is contained in:
442
api/modules/time-tracking/timeService.ts
Normal file
442
api/modules/time-tracking/timeService.ts
Normal file
@@ -0,0 +1,442 @@
|
||||
import type {
|
||||
DayTimeData,
|
||||
MonthTimeData,
|
||||
YearTimeData,
|
||||
TimingSession,
|
||||
TabRecord,
|
||||
TabType,
|
||||
TimeTrackingEvent,
|
||||
} from '../../../shared/types.js'
|
||||
import { getTabTypeFromPath, getFileNameFromPath } from '../../../shared/utils/tabType.js'
|
||||
import { logger } from '../../utils/logger.js'
|
||||
import { HeartbeatService, createHeartbeatService } from './heartbeatService.js'
|
||||
import { SessionPersistence, createSessionPersistence } from './sessionPersistence.js'
|
||||
|
||||
const generateId = (): string => {
|
||||
return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
|
||||
}
|
||||
|
||||
export interface TimeTrackerServiceDependencies {
|
||||
heartbeatService: HeartbeatService
|
||||
persistence: SessionPersistence
|
||||
}
|
||||
|
||||
export interface TimeTrackerServiceConfig {
|
||||
heartbeatIntervalMs?: number
|
||||
}
|
||||
|
||||
class TimeTrackerService {
|
||||
private currentSession: TimingSession | null = null
|
||||
private currentTabRecord: TabRecord | null = null
|
||||
private isPaused: boolean = false
|
||||
private todayDuration: number = 0
|
||||
private _initialized: boolean = false
|
||||
private static _initializationPromise: Promise<void> | null = null
|
||||
|
||||
private readonly heartbeatService: HeartbeatService
|
||||
private readonly persistence: SessionPersistence
|
||||
|
||||
private constructor(
|
||||
dependencies: TimeTrackerServiceDependencies
|
||||
) {
|
||||
this.heartbeatService = dependencies.heartbeatService
|
||||
this.persistence = dependencies.persistence
|
||||
}
|
||||
|
||||
static async create(
|
||||
config?: TimeTrackerServiceConfig
|
||||
): Promise<TimeTrackerService> {
|
||||
const heartbeatService = createHeartbeatService(config?.heartbeatIntervalMs)
|
||||
const persistence = createSessionPersistence()
|
||||
|
||||
const instance = new TimeTrackerService({
|
||||
heartbeatService,
|
||||
persistence
|
||||
})
|
||||
|
||||
await instance.initialize()
|
||||
return instance
|
||||
}
|
||||
|
||||
static async createWithDependencies(
|
||||
dependencies: TimeTrackerServiceDependencies
|
||||
): Promise<TimeTrackerService> {
|
||||
const instance = new TimeTrackerService(dependencies)
|
||||
await instance.initialize()
|
||||
return instance
|
||||
}
|
||||
|
||||
private async initialize(): Promise<void> {
|
||||
if (this._initialized) {
|
||||
return
|
||||
}
|
||||
|
||||
if (TimeTrackerService._initializationPromise) {
|
||||
await TimeTrackerService._initializationPromise
|
||||
return
|
||||
}
|
||||
|
||||
TimeTrackerService._initializationPromise = this.loadCurrentState()
|
||||
await TimeTrackerService._initializationPromise
|
||||
this._initialized = true
|
||||
TimeTrackerService._initializationPromise = null
|
||||
|
||||
this.heartbeatService.setCallback(async () => {
|
||||
if (this.currentSession && !this.isPaused) {
|
||||
try {
|
||||
this.heartbeatService.updateHeartbeat()
|
||||
await this.updateCurrentTabDuration()
|
||||
await this.saveCurrentState()
|
||||
await this.updateTodayDataRealtime()
|
||||
} catch (err) {
|
||||
logger.error('Heartbeat update failed:', err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
ensureInitialized(): void {
|
||||
if (!this._initialized) {
|
||||
throw new Error('TimeTrackerService 未初始化,请使用 TimeTrackerService.create() 创建实例')
|
||||
}
|
||||
}
|
||||
|
||||
private async loadCurrentState(): Promise<void> {
|
||||
const now = new Date()
|
||||
const todayData = await this.persistence.getDayData(now.getFullYear(), now.getMonth() + 1, now.getDate())
|
||||
this.todayDuration = todayData.totalDuration
|
||||
|
||||
const state = await this.persistence.loadCurrentState()
|
||||
if (state.session && state.session.status === 'active') {
|
||||
const sessionStart = new Date(state.session.startTime)
|
||||
const now = new Date()
|
||||
if (now.getTime() - sessionStart.getTime() < 24 * 60 * 60 * 1000) {
|
||||
this.currentSession = state.session
|
||||
this.isPaused = state.isPaused
|
||||
if (state.currentTabRecord) {
|
||||
this.currentTabRecord = state.currentTabRecord
|
||||
}
|
||||
this.heartbeatService.restoreState({ lastHeartbeat: state.lastHeartbeat })
|
||||
} else {
|
||||
await this.endSession()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async saveCurrentState(): Promise<void> {
|
||||
await this.persistence.saveCurrentState({
|
||||
session: this.currentSession,
|
||||
currentTabRecord: this.currentTabRecord,
|
||||
isPaused: this.isPaused,
|
||||
lastHeartbeat: this.heartbeatService.getLastHeartbeat().toISOString()
|
||||
})
|
||||
}
|
||||
|
||||
async startSession(): Promise<TimingSession> {
|
||||
if (this.currentSession && this.currentSession.status === 'active') {
|
||||
return this.currentSession
|
||||
}
|
||||
|
||||
const now = new Date()
|
||||
this.currentSession = {
|
||||
id: generateId(),
|
||||
startTime: now.toISOString(),
|
||||
duration: 0,
|
||||
status: 'active',
|
||||
tabRecords: []
|
||||
}
|
||||
this.isPaused = false
|
||||
this.heartbeatService.updateHeartbeat()
|
||||
|
||||
this.heartbeatService.start()
|
||||
await this.saveCurrentState()
|
||||
|
||||
return this.currentSession
|
||||
}
|
||||
|
||||
async pauseSession(): Promise<void> {
|
||||
if (!this.currentSession || this.isPaused) return
|
||||
|
||||
this.isPaused = true
|
||||
await this.updateCurrentTabDuration()
|
||||
await this.saveCurrentState()
|
||||
}
|
||||
|
||||
async resumeSession(): Promise<void> {
|
||||
if (!this.currentSession || !this.isPaused) return
|
||||
|
||||
this.isPaused = false
|
||||
this.heartbeatService.updateHeartbeat()
|
||||
|
||||
if (this.currentTabRecord) {
|
||||
const now = new Date()
|
||||
const timeStr = `${now.getHours().toString().padStart(2, '0')}:${now.getMinutes().toString().padStart(2, '0')}:${now.getSeconds().toString().padStart(2, '0')}`
|
||||
this.currentTabRecord.focusedPeriods.push({ start: timeStr, end: timeStr })
|
||||
}
|
||||
|
||||
await this.saveCurrentState()
|
||||
}
|
||||
|
||||
async endSession(): Promise<void> {
|
||||
if (!this.currentSession) return
|
||||
|
||||
this.heartbeatService.stop()
|
||||
|
||||
await this.updateCurrentTabDuration()
|
||||
|
||||
const now = new Date()
|
||||
this.currentSession.endTime = now.toISOString()
|
||||
this.currentSession.status = 'ended'
|
||||
|
||||
const startTime = new Date(this.currentSession.startTime)
|
||||
this.currentSession.duration = Math.floor((now.getTime() - startTime.getTime()) / 1000)
|
||||
|
||||
await this.persistence.saveSessionToDay(this.currentSession)
|
||||
|
||||
this.todayDuration += this.currentSession.duration
|
||||
|
||||
this.currentSession = null
|
||||
this.currentTabRecord = null
|
||||
this.isPaused = false
|
||||
|
||||
await this.persistence.clearCurrentState()
|
||||
}
|
||||
|
||||
private async updateCurrentTabDuration(): Promise<void> {
|
||||
if (!this.currentSession || !this.currentTabRecord) return
|
||||
|
||||
const now = new Date()
|
||||
const periods = this.currentTabRecord.focusedPeriods
|
||||
|
||||
if (periods.length > 0) {
|
||||
const lastPeriod = periods[periods.length - 1]
|
||||
const [h, m, s] = lastPeriod.start.split(':').map(Number)
|
||||
const startSeconds = h * 3600 + m * 60 + s
|
||||
const currentSeconds = now.getHours() * 3600 + now.getMinutes() * 60 + now.getSeconds()
|
||||
|
||||
this.currentTabRecord.duration = currentSeconds - startSeconds
|
||||
lastPeriod.end = `${now.getHours().toString().padStart(2, '0')}:${now.getMinutes().toString().padStart(2, '0')}:${now.getSeconds().toString().padStart(2, '0')}`
|
||||
}
|
||||
}
|
||||
|
||||
private async updateTodayDataRealtime(): Promise<void> {
|
||||
if (!this.currentSession) return
|
||||
|
||||
const now = new Date()
|
||||
const year = now.getFullYear()
|
||||
const month = now.getMonth() + 1
|
||||
const day = now.getDate()
|
||||
|
||||
const dayData = await this.persistence.updateDayDataRealtime(
|
||||
year,
|
||||
month,
|
||||
day,
|
||||
this.currentSession,
|
||||
this.currentTabRecord
|
||||
)
|
||||
|
||||
this.todayDuration = dayData.totalDuration
|
||||
|
||||
await this.persistence.recalculateMonthSummary(year, month, this.todayDuration)
|
||||
await this.persistence.recalculateYearSummary(year)
|
||||
}
|
||||
|
||||
async handleTabSwitch(tabInfo: { tabId: string; filePath: string | null }): Promise<void> {
|
||||
if (!this.currentSession || this.isPaused) return
|
||||
|
||||
await this.updateCurrentTabDuration()
|
||||
|
||||
if (this.currentTabRecord && this.currentTabRecord.duration > 0) {
|
||||
this.currentSession.tabRecords.push({ ...this.currentTabRecord })
|
||||
}
|
||||
|
||||
const now = new Date()
|
||||
const timeStr = `${now.getHours().toString().padStart(2, '0')}:${now.getMinutes().toString().padStart(2, '0')}:${now.getSeconds().toString().padStart(2, '0')}`
|
||||
|
||||
this.currentTabRecord = {
|
||||
tabId: tabInfo.tabId,
|
||||
filePath: tabInfo.filePath,
|
||||
fileName: getFileNameFromPath(tabInfo.filePath),
|
||||
tabType: getTabTypeFromPath(tabInfo.filePath),
|
||||
duration: 0,
|
||||
focusedPeriods: [{ start: timeStr, end: timeStr }]
|
||||
}
|
||||
|
||||
await this.saveCurrentState()
|
||||
}
|
||||
|
||||
async handleEvent(event: TimeTrackingEvent): Promise<void> {
|
||||
switch (event.type) {
|
||||
case 'window-focus':
|
||||
if (!this.currentSession) {
|
||||
await this.startSession()
|
||||
if (event.tabInfo) {
|
||||
await this.handleTabSwitch(event.tabInfo)
|
||||
}
|
||||
} else {
|
||||
await this.resumeSession()
|
||||
await this.updateTodayDataRealtime()
|
||||
}
|
||||
break
|
||||
case 'window-blur':
|
||||
await this.pauseSession()
|
||||
await this.updateTodayDataRealtime()
|
||||
break
|
||||
case 'app-quit':
|
||||
await this.endSession()
|
||||
break
|
||||
case 'tab-switch':
|
||||
case 'tab-open':
|
||||
if (!this.currentSession) {
|
||||
await this.startSession()
|
||||
}
|
||||
if (event.tabInfo) {
|
||||
await this.handleTabSwitch(event.tabInfo)
|
||||
}
|
||||
await this.updateTodayDataRealtime()
|
||||
break
|
||||
case 'tab-close':
|
||||
await this.updateCurrentTabDuration()
|
||||
await this.updateTodayDataRealtime()
|
||||
break
|
||||
case 'heartbeat':
|
||||
if (this.currentSession && !this.isPaused) {
|
||||
this.heartbeatService.updateHeartbeat()
|
||||
await this.updateCurrentTabDuration()
|
||||
await this.saveCurrentState()
|
||||
await this.updateTodayDataRealtime()
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
async getDayData(year: number, month: number, day: number): Promise<DayTimeData> {
|
||||
return this.persistence.getDayData(year, month, day)
|
||||
}
|
||||
|
||||
async getWeekData(startDate: Date): Promise<DayTimeData[]> {
|
||||
const result: DayTimeData[] = []
|
||||
for (let i = 0; i < 7; i++) {
|
||||
const date = new Date(startDate)
|
||||
date.setDate(date.getDate() + i)
|
||||
const data = await this.persistence.getDayData(date.getFullYear(), date.getMonth() + 1, date.getDate())
|
||||
result.push(data)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
async getMonthData(year: number, month: number): Promise<MonthTimeData> {
|
||||
return this.persistence.getMonthData(year, month)
|
||||
}
|
||||
|
||||
async getYearData(year: number): Promise<YearTimeData> {
|
||||
return this.persistence.getYearData(year)
|
||||
}
|
||||
|
||||
getCurrentState(): { isRunning: boolean; isPaused: boolean; currentSession: TimingSession | null; todayDuration: number; currentTabRecord: TabRecord | null } {
|
||||
return {
|
||||
isRunning: this.currentSession !== null,
|
||||
isPaused: this.isPaused,
|
||||
currentSession: this.currentSession,
|
||||
todayDuration: this.todayDuration,
|
||||
currentTabRecord: this.currentTabRecord
|
||||
}
|
||||
}
|
||||
|
||||
async getStats(year?: number, month?: number): Promise<{
|
||||
totalDuration: number
|
||||
activeDays: number
|
||||
averageDaily: number
|
||||
longestDay: { date: string; duration: number } | null
|
||||
longestSession: { date: string; duration: number } | null
|
||||
topTabs: Array<{ fileName: string; duration: number; percentage: number }>
|
||||
tabTypeDistribution: Array<{ tabType: TabType; duration: number; percentage: number }>
|
||||
}> {
|
||||
const now = new Date()
|
||||
const targetYear = year || now.getFullYear()
|
||||
const targetMonth = month
|
||||
|
||||
let totalDuration = 0
|
||||
let activeDays = 0
|
||||
let longestDay: { date: string; duration: number } | null = null
|
||||
let longestSession: { date: string; duration: number } | null = null
|
||||
const tabDurations: Record<string, number> = {}
|
||||
const tabTypeDurations: Record<TabType, number> = {} as Record<TabType, number>
|
||||
|
||||
if (targetMonth) {
|
||||
const monthData = await this.persistence.getMonthData(targetYear, targetMonth)
|
||||
totalDuration = monthData.monthlyTotal
|
||||
activeDays = monthData.activeDays
|
||||
|
||||
for (const [day, summary] of Object.entries(monthData.days)) {
|
||||
if (!longestDay || summary.totalDuration > longestDay.duration) {
|
||||
longestDay = { date: `${targetYear}-${targetMonth.toString().padStart(2, '0')}-${day}`, duration: summary.totalDuration }
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const yearData = await this.persistence.getYearData(targetYear)
|
||||
totalDuration = yearData.yearlyTotal
|
||||
activeDays = yearData.totalActiveDays
|
||||
|
||||
for (const [month, summary] of Object.entries(yearData.months)) {
|
||||
if (!longestDay || summary.totalDuration > longestDay.duration) {
|
||||
longestDay = { date: `${targetYear}-${month}`, duration: summary.totalDuration }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
totalDuration,
|
||||
activeDays,
|
||||
averageDaily: activeDays > 0 ? Math.floor(totalDuration / activeDays) : 0,
|
||||
longestDay,
|
||||
longestSession,
|
||||
topTabs: Object.entries(tabDurations)
|
||||
.map(([fileName, duration]) => ({
|
||||
fileName,
|
||||
duration,
|
||||
percentage: totalDuration > 0 ? Math.round((duration / totalDuration) * 100) : 0
|
||||
}))
|
||||
.sort((a, b) => b.duration - a.duration)
|
||||
.slice(0, 10),
|
||||
tabTypeDistribution: Object.entries(tabTypeDurations)
|
||||
.map(([tabType, duration]) => ({
|
||||
tabType: tabType as TabType,
|
||||
duration,
|
||||
percentage: totalDuration > 0 ? Math.round((duration / totalDuration) * 100) : 0
|
||||
}))
|
||||
.sort((a, b) => b.duration - a.duration)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let _timeTrackerService: TimeTrackerService | null = null
|
||||
|
||||
export const getTimeTrackerService = (): TimeTrackerService => {
|
||||
if (!_timeTrackerService) {
|
||||
throw new Error('TimeTrackerService 未初始化,请先调用 initializeTimeTrackerService()')
|
||||
}
|
||||
return _timeTrackerService
|
||||
}
|
||||
|
||||
export const initializeTimeTrackerService = async (
|
||||
config?: TimeTrackerServiceConfig
|
||||
): Promise<TimeTrackerService> => {
|
||||
if (_timeTrackerService) {
|
||||
return _timeTrackerService
|
||||
}
|
||||
_timeTrackerService = await TimeTrackerService.create(config)
|
||||
return _timeTrackerService
|
||||
}
|
||||
|
||||
export const initializeTimeTrackerServiceWithDependencies = async (
|
||||
dependencies: TimeTrackerServiceDependencies
|
||||
): Promise<TimeTrackerService> => {
|
||||
if (_timeTrackerService) {
|
||||
return _timeTrackerService
|
||||
}
|
||||
_timeTrackerService = await TimeTrackerService.createWithDependencies(dependencies)
|
||||
return _timeTrackerService
|
||||
}
|
||||
|
||||
export { TimeTrackerService }
|
||||
Reference in New Issue
Block a user