Initial commit
This commit is contained in:
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 }
|
||||
Reference in New Issue
Block a user