Files
XCDesktop/api/modules/time-tracking/sessionPersistence.ts
2026-03-08 01:34:54 +08:00

368 lines
12 KiB
TypeScript

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 }