467 lines
15 KiB
TypeScript
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 }
|