Initial commit
This commit is contained in:
80
api/modules/time-tracking/heartbeatService.ts
Normal file
80
api/modules/time-tracking/heartbeatService.ts
Normal 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)
|
||||
}
|
||||
38
api/modules/time-tracking/index.ts
Normal file
38
api/modules/time-tracking/index.ts
Normal 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
|
||||
131
api/modules/time-tracking/routes.ts
Normal file
131
api/modules/time-tracking/routes.ts
Normal 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
|
||||
}
|
||||
367
api/modules/time-tracking/sessionPersistence.ts
Normal file
367
api/modules/time-tracking/sessionPersistence.ts
Normal 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 }
|
||||
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