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 saveCurrentState(state: PersistedSessionState): Promise clearCurrentState(): Promise saveSessionToDay(session: TimingSession): Promise getDayData(year: number, month: number, day: number): Promise getMonthData(year: number, month: number): Promise getYearData(year: number): Promise updateDayDataRealtime( year: number, month: number, day: number, session: TimingSession, currentTabRecord: TabRecord | null ): Promise updateMonthSummary(year: number, month: number, day: number, duration: number): Promise updateYearSummary(year: number, month: number, duration: number): Promise recalculateMonthSummary(year: number, month: number, todayDuration: number): Promise recalculateYearSummary(year: number): Promise } 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 => { 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 { 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 { 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 { try { await fs.unlink(this.stateFilePath) } catch (err) { logger.debug('Session state file already removed or does not exist') } } async saveSessionToDay(session: TimingSession): Promise { 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 { 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 { const filePath = getMonthFilePath(year, month) try { const content = await fs.readFile(filePath, 'utf-8') const data = JSON.parse(content) data.activeDays = Object.values(data.days).filter((d: any) => d.totalDuration > 0).length return data } catch (err) { return createEmptyMonthData(year, month) } } async getYearData(year: number): Promise { const filePath = getYearFilePath(year) try { const content = await fs.readFile(filePath, 'utf-8') const data = JSON.parse(content) data.totalActiveDays = Object.values(data.months).filter((m: any) => m.totalDuration > 0).length return data } catch (err) { return createEmptyYearData(year) } } async updateDayDataRealtime( year: number, month: number, day: number, session: TimingSession, currentTabRecord: TabRecord | null ): Promise { 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 = dayData.totalDuration - oldDuration + currentSessionDuration } 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 { 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.values(monthData.days).filter(d => d.totalDuration > 0).length monthData.averageDaily = monthData.activeDays > 0 ? Math.floor(monthData.monthlyTotal / monthData.activeDays) : 0 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 { 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) => { const hasActiveDays = m.totalDuration > 0 ? 1 : 0 return sum + hasActiveDays }, 0) const activeMonthCount = Object.values(yearData.months).filter(m => m.totalDuration > 0).length yearData.averageMonthly = activeMonthCount > 0 ? Math.floor(yearData.yearlyTotal / activeMonthCount) : 0 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 { 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.values(monthData.days).filter(d => d.totalDuration > 0).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 { 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) => { const hasActiveDays = m.totalDuration > 0 ? 1 : 0 return sum + hasActiveDays }, 0) const activeMonthCount = Object.values(yearData.months).filter(m => m.totalDuration > 0).length yearData.averageMonthly = activeMonthCount > 0 ? Math.floor(yearData.yearlyTotal / activeMonthCount) : 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 }