368 lines
12 KiB
TypeScript
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 }
|