Files
XCDesktop/api/modules/time-tracking/timeService.ts

467 lines
15 KiB
TypeScript

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 = 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<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 }