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 | 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 { 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 { const instance = new TimeTrackerService(dependencies) await instance.initialize() return instance } private async initialize(): Promise { 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 { 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 { await this.persistence.saveCurrentState({ session: this.currentSession, currentTabRecord: this.currentTabRecord, isPaused: this.isPaused, lastHeartbeat: this.heartbeatService.getLastHeartbeat().toISOString() }) } async startSession(): Promise { 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 { if (!this.currentSession || this.isPaused) return this.isPaused = true await this.updateCurrentTabDuration() await this.saveCurrentState() } async resumeSession(): Promise { 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 { 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 { 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 { 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 { 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 { 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 { return this.persistence.getDayData(year, month, day) } async getWeekData(startDate: Date): Promise { 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 { return this.persistence.getMonthData(year, month) } async getYearData(year: number): Promise { 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 = {} const tabTypeDurations: Record = {} as Record if (targetMonth) { const monthData = await this.persistence.getMonthData(targetYear, targetMonth) totalDuration = monthData.monthlyTotal activeDays = Object.values(monthData.days).filter(d => d.totalDuration > 0).length 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 } } for (const tab of summary.topTabs || []) { tabDurations[tab.fileName] = (tabDurations[tab.fileName] || 0) + tab.duration } } const dayData = await this.persistence.getDayData(targetYear, targetMonth, 1) for (const session of dayData.sessions) { for (const record of session.tabRecords) { const key = record.filePath || record.fileName tabDurations[key] = (tabDurations[key] || 0) + record.duration tabTypeDurations[record.tabType] = (tabTypeDurations[record.tabType] || 0) + record.duration } } } else { const yearData = await this.persistence.getYearData(targetYear) totalDuration = yearData.yearlyTotal activeDays = Object.values(yearData.months).reduce((sum, m) => { return sum + Object.entries(m).filter(([_, d]) => (d as { totalDuration: number }).totalDuration > 0).length }, 0) for (const [month, summary] of Object.entries(yearData.months)) { if (!longestDay || summary.totalDuration > longestDay.duration) { longestDay = { date: `${targetYear}-${month}`, duration: summary.totalDuration } } } for (let m = 1; m <= 12; m++) { const monthStr = m.toString().padStart(2, '0') const monthData = await this.persistence.getMonthData(targetYear, m) for (const dayData of Object.values(monthData.days)) { for (const tab of dayData.topTabs || []) { tabDurations[tab.fileName] = (tabDurations[tab.fileName] || 0) + tab.duration } } } } 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 => { if (_timeTrackerService) { return _timeTrackerService } _timeTrackerService = await TimeTrackerService.create(config) return _timeTrackerService } export const initializeTimeTrackerServiceWithDependencies = async ( dependencies: TimeTrackerServiceDependencies ): Promise => { if (_timeTrackerService) { return _timeTrackerService } _timeTrackerService = await TimeTrackerService.createWithDependencies(dependencies) return _timeTrackerService } export { TimeTrackerService }