Initial commit

This commit is contained in:
2026-03-08 01:34:54 +08:00
commit 1f104f73c8
441 changed files with 64911 additions and 0 deletions

View File

@@ -0,0 +1,80 @@
import { logger } from '../../utils/logger.js'
export interface HeartbeatCallback {
(): Promise<void>
}
export interface HeartbeatState {
lastHeartbeat: Date
isRunning: boolean
}
const DEFAULT_HEARTBEAT_INTERVAL = 60000
export class HeartbeatService {
private interval: NodeJS.Timeout | null = null
private lastHeartbeat: Date = new Date()
private readonly intervalMs: number
private callback: HeartbeatCallback | null = null
constructor(intervalMs: number = DEFAULT_HEARTBEAT_INTERVAL) {
this.intervalMs = intervalMs
}
setCallback(callback: HeartbeatCallback): void {
this.callback = callback
}
start(): void {
if (this.interval) {
this.stop()
}
this.interval = setInterval(async () => {
if (this.callback) {
try {
this.lastHeartbeat = new Date()
await this.callback()
} catch (err) {
logger.error('Heartbeat callback failed:', err)
}
}
}, this.intervalMs)
this.lastHeartbeat = new Date()
}
stop(): void {
if (this.interval) {
clearInterval(this.interval)
this.interval = null
}
}
isRunning(): boolean {
return this.interval !== null
}
getLastHeartbeat(): Date {
return this.lastHeartbeat
}
updateHeartbeat(): void {
this.lastHeartbeat = new Date()
}
getState(): HeartbeatState {
return {
lastHeartbeat: this.lastHeartbeat,
isRunning: this.isRunning()
}
}
restoreState(state: { lastHeartbeat: string }): void {
this.lastHeartbeat = new Date(state.lastHeartbeat)
}
}
export const createHeartbeatService = (intervalMs?: number): HeartbeatService => {
return new HeartbeatService(intervalMs)
}

View File

@@ -0,0 +1,38 @@
import type { Router } from 'express'
import type { ServiceContainer } from '../../infra/container.js'
import { createApiModule } from '../../infra/createModule.js'
import { TIME_TRACKING_MODULE } from '../../../shared/modules/time-tracking/index.js'
import {
TimeTrackerService,
initializeTimeTrackerService,
type TimeTrackerServiceConfig
} from './timeService.js'
import { createTimeTrackingRoutes } from './routes.js'
export * from './timeService.js'
export * from './heartbeatService.js'
export * from './sessionPersistence.js'
export * from './routes.js'
export interface TimeTrackingModuleConfig {
config?: TimeTrackerServiceConfig
}
export const createTimeTrackingModule = (moduleConfig: TimeTrackingModuleConfig = {}) => {
let serviceInstance: TimeTrackerService | undefined
return createApiModule(TIME_TRACKING_MODULE, {
routes: (container: ServiceContainer): Router => {
const timeTrackerService = container.getSync<TimeTrackerService>('timeTrackerService')
return createTimeTrackingRoutes({ timeTrackerService })
},
lifecycle: {
onLoad: async (container: ServiceContainer): Promise<void> => {
serviceInstance = await initializeTimeTrackerService(moduleConfig.config)
container.register('timeTrackerService', () => serviceInstance!)
},
},
})
}
export default createTimeTrackingModule

View File

@@ -0,0 +1,131 @@
import express, { type Request, type Response, type Router } from 'express'
import { asyncHandler } from '../../utils/asyncHandler.js'
import { successResponse } from '../../utils/response.js'
import { TimeTrackerService } from './timeService.js'
import type { TimeTrackingEvent } from '../../../shared/types.js'
export interface TimeTrackingRoutesDependencies {
timeTrackerService: TimeTrackerService
}
export const createTimeTrackingRoutes = (deps: TimeTrackingRoutesDependencies): Router => {
const router = express.Router()
const { timeTrackerService } = deps
router.get(
'/current',
asyncHandler(async (_req: Request, res: Response) => {
const state = timeTrackerService.getCurrentState()
successResponse(res, {
isRunning: state.isRunning,
isPaused: state.isPaused,
currentSession: state.currentSession ? {
id: state.currentSession.id,
startTime: state.currentSession.startTime,
duration: state.currentSession.duration,
currentTab: state.currentTabRecord ? {
tabId: state.currentTabRecord.tabId,
fileName: state.currentTabRecord.fileName,
tabType: state.currentTabRecord.tabType
} : null
} : null,
todayDuration: state.todayDuration
})
})
)
router.post(
'/event',
asyncHandler(async (req: Request, res: Response) => {
const event = req.body as TimeTrackingEvent
await timeTrackerService.handleEvent(event)
successResponse(res, null)
})
)
router.get(
'/day/:date',
asyncHandler(async (req: Request, res: Response) => {
const { date } = req.params
const [year, month, day] = date.split('-').map(Number)
const data = await timeTrackerService.getDayData(year, month, day)
const sessionsCount = data.sessions.length
const averageSessionDuration = sessionsCount > 0
? Math.floor(data.totalDuration / sessionsCount)
: 0
const longestSession = data.sessions.reduce((max, s) =>
s.duration > max ? s.duration : max, 0)
const topTabs = Object.entries(data.tabSummary)
.map(([_, summary]) => ({
fileName: summary.fileName,
duration: summary.totalDuration
}))
.sort((a, b) => b.duration - a.duration)
.slice(0, 5)
successResponse(res, {
...data,
stats: {
sessionsCount,
averageSessionDuration,
longestSession,
topTabs
}
})
})
)
router.get(
'/week/:startDate',
asyncHandler(async (req: Request, res: Response) => {
const { startDate } = req.params
const [year, month, day] = startDate.split('-').map(Number)
const start = new Date(year, month - 1, day)
const data = await timeTrackerService.getWeekData(start)
const totalDuration = data.reduce((sum, d) => sum + d.totalDuration, 0)
const activeDays = data.filter(d => d.totalDuration > 0).length
successResponse(res, {
days: data,
totalDuration,
activeDays,
averageDaily: activeDays > 0 ? Math.floor(totalDuration / activeDays) : 0
})
})
)
router.get(
'/month/:yearMonth',
asyncHandler(async (req: Request, res: Response) => {
const { yearMonth } = req.params
const [year, month] = yearMonth.split('-').map(Number)
const data = await timeTrackerService.getMonthData(year, month)
successResponse(res, data)
})
)
router.get(
'/year/:year',
asyncHandler(async (req: Request, res: Response) => {
const { year } = req.params
const data = await timeTrackerService.getYearData(parseInt(year))
successResponse(res, data)
})
)
router.get(
'/stats',
asyncHandler(async (req: Request, res: Response) => {
const year = req.query.year ? parseInt(req.query.year as string) : undefined
const month = req.query.month ? parseInt(req.query.month as string) : undefined
const stats = await timeTrackerService.getStats(year, month)
successResponse(res, stats)
})
)
return router
}

View File

@@ -0,0 +1,367 @@
import fs from 'fs/promises'
import path from 'path'
import { NOTEBOOK_ROOT } from '../../config/paths.js'
import type {
TimingSession,
TabRecord,
DayTimeData,
MonthTimeData,
YearTimeData
} from '../../../shared/types.js'
import { logger } from '../../utils/logger.js'
const TIME_ROOT = path.join(NOTEBOOK_ROOT, 'time')
export interface PersistedSessionState {
session: TimingSession | null
currentTabRecord: TabRecord | null
isPaused: boolean
lastHeartbeat: string
}
export interface SessionPersistence {
loadCurrentState(): Promise<PersistedSessionState>
saveCurrentState(state: PersistedSessionState): Promise<void>
clearCurrentState(): Promise<void>
saveSessionToDay(session: TimingSession): Promise<void>
getDayData(year: number, month: number, day: number): Promise<DayTimeData>
getMonthData(year: number, month: number): Promise<MonthTimeData>
getYearData(year: number): Promise<YearTimeData>
updateDayDataRealtime(
year: number,
month: number,
day: number,
session: TimingSession,
currentTabRecord: TabRecord | null
): Promise<DayTimeData>
updateMonthSummary(year: number, month: number, day: number, duration: number): Promise<void>
updateYearSummary(year: number, month: number, duration: number): Promise<void>
recalculateMonthSummary(year: number, month: number, todayDuration: number): Promise<void>
recalculateYearSummary(year: number): Promise<void>
}
const getDayFilePath = (year: number, month: number, day: number): string => {
const monthStr = month.toString().padStart(2, '0')
const dayStr = day.toString().padStart(2, '0')
return path.join(TIME_ROOT, year.toString(), `${year}${monthStr}${dayStr}.json`)
}
const getMonthFilePath = (year: number, month: number): string => {
const monthStr = month.toString().padStart(2, '0')
return path.join(TIME_ROOT, year.toString(), `${year}${monthStr}.json`)
}
const getYearFilePath = (year: number): string => {
return path.join(TIME_ROOT, 'summary', `${year}.json`)
}
const ensureDirExists = async (filePath: string): Promise<void> => {
const dir = path.dirname(filePath)
await fs.mkdir(dir, { recursive: true })
}
const createEmptyDayData = (year: number, month: number, day: number): DayTimeData => ({
date: `${year}-${month.toString().padStart(2, '0')}-${day.toString().padStart(2, '0')}`,
totalDuration: 0,
sessions: [],
tabSummary: {},
lastUpdated: new Date().toISOString()
})
const createEmptyMonthData = (year: number, month: number): MonthTimeData => ({
year,
month,
days: {},
monthlyTotal: 0,
averageDaily: 0,
activeDays: 0,
lastUpdated: new Date().toISOString()
})
const createEmptyYearData = (year: number): YearTimeData => ({
year,
months: {},
yearlyTotal: 0,
averageMonthly: 0,
averageDaily: 0,
totalActiveDays: 0
})
class SessionPersistenceService implements SessionPersistence {
private readonly stateFilePath: string
constructor() {
this.stateFilePath = path.join(TIME_ROOT, '.current-session.json')
}
async loadCurrentState(): Promise<PersistedSessionState> {
try {
const content = await fs.readFile(this.stateFilePath, 'utf-8')
const state = JSON.parse(content)
return {
session: state.session || null,
currentTabRecord: state.currentTabRecord || null,
isPaused: state.isPaused || false,
lastHeartbeat: state.lastHeartbeat || new Date().toISOString()
}
} catch (err) {
logger.debug('No existing session to load or session file corrupted')
return {
session: null,
currentTabRecord: null,
isPaused: false,
lastHeartbeat: new Date().toISOString()
}
}
}
async saveCurrentState(state: PersistedSessionState): Promise<void> {
await ensureDirExists(this.stateFilePath)
await fs.writeFile(this.stateFilePath, JSON.stringify({
session: state.session,
currentTabRecord: state.currentTabRecord,
isPaused: state.isPaused,
lastHeartbeat: state.lastHeartbeat
}), 'utf-8')
}
async clearCurrentState(): Promise<void> {
try {
await fs.unlink(this.stateFilePath)
} catch (err) {
logger.debug('Session state file already removed or does not exist')
}
}
async saveSessionToDay(session: TimingSession): Promise<void> {
const startTime = new Date(session.startTime)
const year = startTime.getFullYear()
const month = startTime.getMonth() + 1
const day = startTime.getDate()
const filePath = getDayFilePath(year, month, day)
await ensureDirExists(filePath)
let dayData = await this.getDayData(year, month, day)
dayData.sessions.push(session)
dayData.totalDuration += session.duration
for (const record of session.tabRecords) {
const key = record.filePath || record.fileName
if (!dayData.tabSummary[key]) {
dayData.tabSummary[key] = {
fileName: record.fileName,
tabType: record.tabType,
totalDuration: 0,
focusCount: 0
}
}
dayData.tabSummary[key].totalDuration += record.duration
dayData.tabSummary[key].focusCount += record.focusedPeriods.length
}
dayData.lastUpdated = new Date().toISOString()
await fs.writeFile(filePath, JSON.stringify(dayData, null, 2), 'utf-8')
await this.updateMonthSummary(year, month, day, session.duration)
await this.updateYearSummary(year, month, session.duration)
}
async getDayData(year: number, month: number, day: number): Promise<DayTimeData> {
const filePath = getDayFilePath(year, month, day)
try {
const content = await fs.readFile(filePath, 'utf-8')
return JSON.parse(content)
} catch (err) {
return createEmptyDayData(year, month, day)
}
}
async getMonthData(year: number, month: number): Promise<MonthTimeData> {
const filePath = getMonthFilePath(year, month)
try {
const content = await fs.readFile(filePath, 'utf-8')
return JSON.parse(content)
} catch (err) {
return createEmptyMonthData(year, month)
}
}
async getYearData(year: number): Promise<YearTimeData> {
const filePath = getYearFilePath(year)
try {
const content = await fs.readFile(filePath, 'utf-8')
return JSON.parse(content)
} catch (err) {
return createEmptyYearData(year)
}
}
async updateDayDataRealtime(
year: number,
month: number,
day: number,
session: TimingSession,
currentTabRecord: TabRecord | null
): Promise<DayTimeData> {
const filePath = getDayFilePath(year, month, day)
await ensureDirExists(filePath)
let dayData = await this.getDayData(year, month, day)
const currentSessionDuration = session.tabRecords.reduce((sum, r) => sum + r.duration, 0) +
(currentTabRecord?.duration || 0)
const existingSessionIndex = dayData.sessions.findIndex(s => s.id === session.id)
const realtimeSession: TimingSession = {
...session,
duration: currentSessionDuration,
tabRecords: currentTabRecord
? [...session.tabRecords, currentTabRecord]
: session.tabRecords
}
if (existingSessionIndex >= 0) {
const oldDuration = dayData.sessions[existingSessionIndex].duration
dayData.sessions[existingSessionIndex] = realtimeSession
dayData.totalDuration += currentSessionDuration - oldDuration
} else {
dayData.sessions.push(realtimeSession)
dayData.totalDuration += currentSessionDuration
}
dayData.tabSummary = {}
for (const s of dayData.sessions) {
for (const record of s.tabRecords) {
const key = record.filePath || record.fileName
if (!dayData.tabSummary[key]) {
dayData.tabSummary[key] = {
fileName: record.fileName,
tabType: record.tabType,
totalDuration: 0,
focusCount: 0
}
}
dayData.tabSummary[key].totalDuration += record.duration
dayData.tabSummary[key].focusCount += record.focusedPeriods.length
}
}
dayData.lastUpdated = new Date().toISOString()
await fs.writeFile(filePath, JSON.stringify(dayData, null, 2), 'utf-8')
return dayData
}
async updateMonthSummary(year: number, month: number, day: number, duration: number): Promise<void> {
const filePath = getMonthFilePath(year, month)
await ensureDirExists(filePath)
let monthData = await this.getMonthData(year, month)
const dayStr = day.toString().padStart(2, '0')
if (!monthData.days[dayStr]) {
monthData.days[dayStr] = { totalDuration: 0, sessions: 0, topTabs: [] }
}
monthData.days[dayStr].totalDuration += duration
monthData.days[dayStr].sessions += 1
monthData.monthlyTotal += duration
monthData.activeDays = Object.keys(monthData.days).length
monthData.averageDaily = Math.floor(monthData.monthlyTotal / monthData.activeDays)
monthData.lastUpdated = new Date().toISOString()
await fs.writeFile(filePath, JSON.stringify(monthData, null, 2), 'utf-8')
}
async updateYearSummary(year: number, month: number, duration: number): Promise<void> {
const filePath = getYearFilePath(year)
await ensureDirExists(filePath)
let yearData = await this.getYearData(year)
const monthStr = month.toString().padStart(2, '0')
if (!yearData.months[monthStr]) {
yearData.months[monthStr] = { totalDuration: 0, activeDays: 0 }
}
yearData.months[monthStr].totalDuration += duration
yearData.yearlyTotal += duration
yearData.totalActiveDays = Object.values(yearData.months).reduce((sum, m) => sum + m.activeDays, 0)
const monthCount = Object.keys(yearData.months).length
yearData.averageMonthly = Math.floor(yearData.yearlyTotal / monthCount)
yearData.averageDaily = yearData.totalActiveDays > 0
? Math.floor(yearData.yearlyTotal / yearData.totalActiveDays)
: 0
await fs.writeFile(filePath, JSON.stringify(yearData, null, 2), 'utf-8')
}
async recalculateMonthSummary(year: number, month: number, todayDuration: number): Promise<void> {
const monthFilePath = getMonthFilePath(year, month)
await ensureDirExists(monthFilePath)
let monthData = await this.getMonthData(year, month)
const dayStr = new Date().getDate().toString().padStart(2, '0')
if (!monthData.days[dayStr]) {
monthData.days[dayStr] = { totalDuration: 0, sessions: 0, topTabs: [] }
}
const oldDayDuration = monthData.days[dayStr].totalDuration
monthData.days[dayStr].totalDuration = todayDuration
monthData.monthlyTotal = monthData.monthlyTotal - oldDayDuration + todayDuration
monthData.activeDays = Object.keys(monthData.days).length
monthData.averageDaily = monthData.activeDays > 0
? Math.floor(monthData.monthlyTotal / monthData.activeDays)
: 0
monthData.lastUpdated = new Date().toISOString()
await fs.writeFile(monthFilePath, JSON.stringify(monthData, null, 2), 'utf-8')
}
async recalculateYearSummary(year: number): Promise<void> {
const yearFilePath = getYearFilePath(year)
await ensureDirExists(yearFilePath)
let yearData = await this.getYearData(year)
const monthStr = (new Date().getMonth() + 1).toString().padStart(2, '0')
const monthFilePath = getMonthFilePath(year, new Date().getMonth() + 1)
try {
const monthContent = await fs.readFile(monthFilePath, 'utf-8')
const monthData = JSON.parse(monthContent)
if (!yearData.months[monthStr]) {
yearData.months[monthStr] = { totalDuration: 0, activeDays: 0 }
}
const oldMonthTotal = yearData.months[monthStr].totalDuration
yearData.months[monthStr].totalDuration = monthData.monthlyTotal
yearData.months[monthStr].activeDays = monthData.activeDays
yearData.yearlyTotal = yearData.yearlyTotal - oldMonthTotal + monthData.monthlyTotal
yearData.totalActiveDays = Object.values(yearData.months).reduce((sum, m) => sum + m.activeDays, 0)
const monthCount = Object.keys(yearData.months).length
yearData.averageMonthly = monthCount > 0 ? Math.floor(yearData.yearlyTotal / monthCount) : 0
yearData.averageDaily = yearData.totalActiveDays > 0
? Math.floor(yearData.yearlyTotal / yearData.totalActiveDays)
: 0
} catch (err) {
logger.debug('Month file not found for year summary calculation')
}
await fs.writeFile(yearFilePath, JSON.stringify(yearData, null, 2), 'utf-8')
}
}
export const createSessionPersistence = (): SessionPersistence => {
return new SessionPersistenceService()
}
export { SessionPersistenceService }

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