913 lines
33 KiB
JavaScript
913 lines
33 KiB
JavaScript
import {
|
|
logger
|
|
} from "./chunk-47DJ6YUB.js";
|
|
import {
|
|
NOTEBOOK_ROOT,
|
|
asyncHandler,
|
|
createApiModule,
|
|
defineApiModule,
|
|
defineEndpoints,
|
|
successResponse
|
|
} from "./chunk-74TMTGBG.js";
|
|
|
|
// shared/modules/time-tracking/api.ts
|
|
var TIME_TRACKING_ENDPOINTS = defineEndpoints({
|
|
current: { path: "/current", method: "GET" },
|
|
event: { path: "/event", method: "POST" },
|
|
day: { path: "/day/:date", method: "GET" },
|
|
week: { path: "/week/:startDate", method: "GET" },
|
|
month: { path: "/month/:yearMonth", method: "GET" },
|
|
year: { path: "/year/:year", method: "GET" },
|
|
stats: { path: "/stats", method: "GET" }
|
|
});
|
|
|
|
// shared/modules/time-tracking/index.ts
|
|
var TIME_TRACKING_MODULE = defineApiModule({
|
|
id: "time-tracking",
|
|
name: "\u65F6\u95F4\u7EDF\u8BA1",
|
|
basePath: "/time",
|
|
order: 20,
|
|
version: "1.0.0",
|
|
endpoints: TIME_TRACKING_ENDPOINTS
|
|
});
|
|
|
|
// shared/utils/tabType.ts
|
|
var KNOWN_MODULE_IDS = [
|
|
"home",
|
|
"settings",
|
|
"search",
|
|
"weread",
|
|
"recycle-bin",
|
|
"todo",
|
|
"time-tracking",
|
|
"pydemos"
|
|
];
|
|
function getTabTypeFromPath(filePath) {
|
|
if (!filePath) return "other";
|
|
if (filePath.startsWith("file-transfer-panel")) {
|
|
return "file-transfer";
|
|
}
|
|
if (filePath.startsWith("remote-git://")) {
|
|
return "remote-git";
|
|
}
|
|
if (filePath.startsWith("remote-desktop://")) {
|
|
return "remote-desktop";
|
|
}
|
|
if (filePath.startsWith("remote-") && filePath !== "remote-tab") {
|
|
return "remote-desktop";
|
|
}
|
|
if (filePath === "remote-tab" || filePath === "remote") {
|
|
return "remote";
|
|
}
|
|
for (const moduleId of KNOWN_MODULE_IDS) {
|
|
if (filePath === `${moduleId}-tab` || filePath === moduleId) {
|
|
if (moduleId === "home" || moduleId === "settings" || moduleId === "search" || moduleId === "weread") {
|
|
return "other";
|
|
}
|
|
return moduleId;
|
|
}
|
|
}
|
|
if (filePath.endsWith(".md")) {
|
|
return "markdown";
|
|
}
|
|
return "other";
|
|
}
|
|
function getFileNameFromPath(filePath) {
|
|
if (!filePath) return "\u672A\u77E5";
|
|
if (filePath.startsWith("file-transfer-panel")) {
|
|
const params = new URLSearchParams(filePath.split("?")[1] || "");
|
|
const deviceName = params.get("device") || "";
|
|
return deviceName ? `\u6587\u4EF6\u4F20\u8F93 - ${deviceName}` : "\u6587\u4EF6\u4F20\u8F93";
|
|
}
|
|
for (const moduleId of KNOWN_MODULE_IDS) {
|
|
if (filePath === `${moduleId}-tab` || filePath === moduleId) {
|
|
const names = {
|
|
"home": "\u9996\u9875",
|
|
"settings": "\u8BBE\u7F6E",
|
|
"search": "\u641C\u7D22",
|
|
"weread": "\u5FAE\u4FE1\u8BFB\u4E66",
|
|
"recycle-bin": "\u56DE\u6536\u7AD9",
|
|
"todo": "TODO",
|
|
"time-tracking": "\u65F6\u95F4\u7EDF\u8BA1",
|
|
"pydemos": "Python Demo",
|
|
"remote": "\u8FDC\u7A0B\u684C\u9762"
|
|
};
|
|
return names[moduleId] ?? moduleId;
|
|
}
|
|
}
|
|
const parts = filePath.split("/");
|
|
return parts[parts.length - 1] || filePath;
|
|
}
|
|
|
|
// api/modules/time-tracking/heartbeatService.ts
|
|
var DEFAULT_HEARTBEAT_INTERVAL = 6e4;
|
|
var HeartbeatService = class {
|
|
interval = null;
|
|
lastHeartbeat = /* @__PURE__ */ new Date();
|
|
intervalMs;
|
|
callback = null;
|
|
constructor(intervalMs = DEFAULT_HEARTBEAT_INTERVAL) {
|
|
this.intervalMs = intervalMs;
|
|
}
|
|
setCallback(callback) {
|
|
this.callback = callback;
|
|
}
|
|
start() {
|
|
if (this.interval) {
|
|
this.stop();
|
|
}
|
|
this.interval = setInterval(async () => {
|
|
if (this.callback) {
|
|
try {
|
|
this.lastHeartbeat = /* @__PURE__ */ new Date();
|
|
await this.callback();
|
|
} catch (err) {
|
|
logger.error("Heartbeat callback failed:", err);
|
|
}
|
|
}
|
|
}, this.intervalMs);
|
|
this.lastHeartbeat = /* @__PURE__ */ new Date();
|
|
}
|
|
stop() {
|
|
if (this.interval) {
|
|
clearInterval(this.interval);
|
|
this.interval = null;
|
|
}
|
|
}
|
|
isRunning() {
|
|
return this.interval !== null;
|
|
}
|
|
getLastHeartbeat() {
|
|
return this.lastHeartbeat;
|
|
}
|
|
updateHeartbeat() {
|
|
this.lastHeartbeat = /* @__PURE__ */ new Date();
|
|
}
|
|
getState() {
|
|
return {
|
|
lastHeartbeat: this.lastHeartbeat,
|
|
isRunning: this.isRunning()
|
|
};
|
|
}
|
|
restoreState(state) {
|
|
this.lastHeartbeat = new Date(state.lastHeartbeat);
|
|
}
|
|
};
|
|
var createHeartbeatService = (intervalMs) => {
|
|
return new HeartbeatService(intervalMs);
|
|
};
|
|
|
|
// api/modules/time-tracking/sessionPersistence.ts
|
|
import fs from "fs/promises";
|
|
import path from "path";
|
|
var TIME_ROOT = path.join(NOTEBOOK_ROOT, "time");
|
|
var getDayFilePath = (year, month, day) => {
|
|
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`);
|
|
};
|
|
var getMonthFilePath = (year, month) => {
|
|
const monthStr = month.toString().padStart(2, "0");
|
|
return path.join(TIME_ROOT, year.toString(), `${year}${monthStr}.json`);
|
|
};
|
|
var getYearFilePath = (year) => {
|
|
return path.join(TIME_ROOT, "summary", `${year}.json`);
|
|
};
|
|
var ensureDirExists = async (filePath) => {
|
|
const dir = path.dirname(filePath);
|
|
await fs.mkdir(dir, { recursive: true });
|
|
};
|
|
var createEmptyDayData = (year, month, day) => ({
|
|
date: `${year}-${month.toString().padStart(2, "0")}-${day.toString().padStart(2, "0")}`,
|
|
totalDuration: 0,
|
|
sessions: [],
|
|
tabSummary: {},
|
|
lastUpdated: (/* @__PURE__ */ new Date()).toISOString()
|
|
});
|
|
var createEmptyMonthData = (year, month) => ({
|
|
year,
|
|
month,
|
|
days: {},
|
|
monthlyTotal: 0,
|
|
averageDaily: 0,
|
|
activeDays: 0,
|
|
lastUpdated: (/* @__PURE__ */ new Date()).toISOString()
|
|
});
|
|
var createEmptyYearData = (year) => ({
|
|
year,
|
|
months: {},
|
|
yearlyTotal: 0,
|
|
averageMonthly: 0,
|
|
averageDaily: 0,
|
|
totalActiveDays: 0
|
|
});
|
|
var SessionPersistenceService = class {
|
|
stateFilePath;
|
|
constructor() {
|
|
this.stateFilePath = path.join(TIME_ROOT, ".current-session.json");
|
|
}
|
|
async loadCurrentState() {
|
|
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 || (/* @__PURE__ */ new Date()).toISOString()
|
|
};
|
|
} catch (err) {
|
|
logger.debug("No existing session to load or session file corrupted");
|
|
return {
|
|
session: null,
|
|
currentTabRecord: null,
|
|
isPaused: false,
|
|
lastHeartbeat: (/* @__PURE__ */ new Date()).toISOString()
|
|
};
|
|
}
|
|
}
|
|
async saveCurrentState(state) {
|
|
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() {
|
|
try {
|
|
await fs.unlink(this.stateFilePath);
|
|
} catch (err) {
|
|
logger.debug("Session state file already removed or does not exist");
|
|
}
|
|
}
|
|
async saveSessionToDay(session) {
|
|
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 = (/* @__PURE__ */ 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, month, day) {
|
|
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, month) {
|
|
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) => d.totalDuration > 0).length;
|
|
return data;
|
|
} catch (err) {
|
|
return createEmptyMonthData(year, month);
|
|
}
|
|
}
|
|
async getYearData(year) {
|
|
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) => m.totalDuration > 0).length;
|
|
return data;
|
|
} catch (err) {
|
|
return createEmptyYearData(year);
|
|
}
|
|
}
|
|
async updateDayDataRealtime(year, month, day, session, currentTabRecord) {
|
|
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 = {
|
|
...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 = (/* @__PURE__ */ new Date()).toISOString();
|
|
await fs.writeFile(filePath, JSON.stringify(dayData, null, 2), "utf-8");
|
|
return dayData;
|
|
}
|
|
async updateMonthSummary(year, month, day, duration) {
|
|
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 = (/* @__PURE__ */ new Date()).toISOString();
|
|
await fs.writeFile(filePath, JSON.stringify(monthData, null, 2), "utf-8");
|
|
}
|
|
async updateYearSummary(year, month, duration) {
|
|
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, month, todayDuration) {
|
|
const monthFilePath = getMonthFilePath(year, month);
|
|
await ensureDirExists(monthFilePath);
|
|
let monthData = await this.getMonthData(year, month);
|
|
const dayStr = (/* @__PURE__ */ 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 = (/* @__PURE__ */ new Date()).toISOString();
|
|
await fs.writeFile(monthFilePath, JSON.stringify(monthData, null, 2), "utf-8");
|
|
}
|
|
async recalculateYearSummary(year) {
|
|
const yearFilePath = getYearFilePath(year);
|
|
await ensureDirExists(yearFilePath);
|
|
let yearData = await this.getYearData(year);
|
|
const monthStr = ((/* @__PURE__ */ new Date()).getMonth() + 1).toString().padStart(2, "0");
|
|
const monthFilePath = getMonthFilePath(year, (/* @__PURE__ */ 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");
|
|
}
|
|
};
|
|
var createSessionPersistence = () => {
|
|
return new SessionPersistenceService();
|
|
};
|
|
|
|
// api/modules/time-tracking/timeService.ts
|
|
var generateId = () => {
|
|
return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
|
};
|
|
var TimeTrackerService = class _TimeTrackerService {
|
|
currentSession = null;
|
|
currentTabRecord = null;
|
|
isPaused = false;
|
|
todayDuration = 0;
|
|
_initialized = false;
|
|
static _initializationPromise = null;
|
|
heartbeatService;
|
|
persistence;
|
|
constructor(dependencies) {
|
|
this.heartbeatService = dependencies.heartbeatService;
|
|
this.persistence = dependencies.persistence;
|
|
}
|
|
static async create(config) {
|
|
const heartbeatService = createHeartbeatService(config?.heartbeatIntervalMs);
|
|
const persistence = createSessionPersistence();
|
|
const instance = new _TimeTrackerService({
|
|
heartbeatService,
|
|
persistence
|
|
});
|
|
await instance.initialize();
|
|
return instance;
|
|
}
|
|
static async createWithDependencies(dependencies) {
|
|
const instance = new _TimeTrackerService(dependencies);
|
|
await instance.initialize();
|
|
return instance;
|
|
}
|
|
async initialize() {
|
|
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() {
|
|
if (!this._initialized) {
|
|
throw new Error("TimeTrackerService \u672A\u521D\u59CB\u5316\uFF0C\u8BF7\u4F7F\u7528 TimeTrackerService.create() \u521B\u5EFA\u5B9E\u4F8B");
|
|
}
|
|
}
|
|
async loadCurrentState() {
|
|
const now = /* @__PURE__ */ 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 now2 = /* @__PURE__ */ new Date();
|
|
if (now2.getTime() - sessionStart.getTime() < 24 * 60 * 60 * 1e3) {
|
|
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();
|
|
}
|
|
}
|
|
}
|
|
async saveCurrentState() {
|
|
await this.persistence.saveCurrentState({
|
|
session: this.currentSession,
|
|
currentTabRecord: this.currentTabRecord,
|
|
isPaused: this.isPaused,
|
|
lastHeartbeat: this.heartbeatService.getLastHeartbeat().toISOString()
|
|
});
|
|
}
|
|
async startSession() {
|
|
if (this.currentSession && this.currentSession.status === "active") {
|
|
return this.currentSession;
|
|
}
|
|
const now = /* @__PURE__ */ 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() {
|
|
if (!this.currentSession || this.isPaused) return;
|
|
this.isPaused = true;
|
|
await this.updateCurrentTabDuration();
|
|
await this.saveCurrentState();
|
|
}
|
|
async resumeSession() {
|
|
if (!this.currentSession || !this.isPaused) return;
|
|
this.isPaused = false;
|
|
this.heartbeatService.updateHeartbeat();
|
|
if (this.currentTabRecord) {
|
|
const now = /* @__PURE__ */ 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() {
|
|
if (!this.currentSession) return;
|
|
this.heartbeatService.stop();
|
|
await this.updateCurrentTabDuration();
|
|
const now = /* @__PURE__ */ 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()) / 1e3);
|
|
await this.persistence.saveSessionToDay(this.currentSession);
|
|
this.todayDuration += this.currentSession.duration;
|
|
this.currentSession = null;
|
|
this.currentTabRecord = null;
|
|
this.isPaused = false;
|
|
await this.persistence.clearCurrentState();
|
|
}
|
|
async updateCurrentTabDuration() {
|
|
if (!this.currentSession || !this.currentTabRecord) return;
|
|
const now = /* @__PURE__ */ 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")}`;
|
|
}
|
|
}
|
|
async updateTodayDataRealtime() {
|
|
if (!this.currentSession) return;
|
|
const now = /* @__PURE__ */ 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) {
|
|
if (!this.currentSession || this.isPaused) return;
|
|
await this.updateCurrentTabDuration();
|
|
if (this.currentTabRecord && this.currentTabRecord.duration > 0) {
|
|
this.currentSession.tabRecords.push({ ...this.currentTabRecord });
|
|
}
|
|
const now = /* @__PURE__ */ 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) {
|
|
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, month, day) {
|
|
return this.persistence.getDayData(year, month, day);
|
|
}
|
|
async getWeekData(startDate) {
|
|
const result = [];
|
|
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, month) {
|
|
return this.persistence.getMonthData(year, month);
|
|
}
|
|
async getYearData(year) {
|
|
return this.persistence.getYearData(year);
|
|
}
|
|
getCurrentState() {
|
|
return {
|
|
isRunning: this.currentSession !== null,
|
|
isPaused: this.isPaused,
|
|
currentSession: this.currentSession,
|
|
todayDuration: this.todayDuration,
|
|
currentTabRecord: this.currentTabRecord
|
|
};
|
|
}
|
|
async getStats(year, month) {
|
|
const now = /* @__PURE__ */ new Date();
|
|
const targetYear = year || now.getFullYear();
|
|
const targetMonth = month;
|
|
let totalDuration = 0;
|
|
let activeDays = 0;
|
|
let longestDay = null;
|
|
let longestSession = null;
|
|
const tabDurations = {};
|
|
const tabTypeDurations = {};
|
|
if (targetMonth) {
|
|
const monthData = await this.persistence.getMonthData(targetYear, targetMonth);
|
|
totalDuration = monthData.monthlyTotal;
|
|
activeDays = Object.values(monthData.days).filter((d) => d.totalDuration > 0).length;
|
|
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 };
|
|
}
|
|
for (const tab of summary.topTabs || []) {
|
|
tabDurations[tab.fileName] = (tabDurations[tab.fileName] || 0) + tab.duration;
|
|
}
|
|
}
|
|
const dayData = await this.persistence.getDayData(targetYear, targetMonth, 1);
|
|
for (const session of dayData.sessions) {
|
|
for (const record of session.tabRecords) {
|
|
const key = record.filePath || record.fileName;
|
|
tabDurations[key] = (tabDurations[key] || 0) + record.duration;
|
|
tabTypeDurations[record.tabType] = (tabTypeDurations[record.tabType] || 0) + record.duration;
|
|
}
|
|
}
|
|
} else {
|
|
const yearData = await this.persistence.getYearData(targetYear);
|
|
totalDuration = yearData.yearlyTotal;
|
|
activeDays = Object.values(yearData.months).reduce((sum, m) => sum + m.activeDays, 0);
|
|
for (const [month2, summary] of Object.entries(yearData.months)) {
|
|
if (!longestDay || summary.totalDuration > longestDay.duration) {
|
|
longestDay = { date: `${targetYear}-${month2}`, duration: summary.totalDuration };
|
|
}
|
|
}
|
|
for (let m = 1; m <= 12; m++) {
|
|
const monthStr = m.toString().padStart(2, "0");
|
|
const monthData = await this.persistence.getMonthData(targetYear, m);
|
|
for (const dayData of Object.values(monthData.days)) {
|
|
for (const tab of dayData.topTabs || []) {
|
|
tabDurations[tab.fileName] = (tabDurations[tab.fileName] || 0) + tab.duration;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
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,
|
|
duration,
|
|
percentage: totalDuration > 0 ? Math.round(duration / totalDuration * 100) : 0
|
|
})).sort((a, b) => b.duration - a.duration)
|
|
};
|
|
}
|
|
};
|
|
var _timeTrackerService = null;
|
|
var getTimeTrackerService = () => {
|
|
if (!_timeTrackerService) {
|
|
throw new Error("TimeTrackerService \u672A\u521D\u59CB\u5316\uFF0C\u8BF7\u5148\u8C03\u7528 initializeTimeTrackerService()");
|
|
}
|
|
return _timeTrackerService;
|
|
};
|
|
var initializeTimeTrackerService = async (config) => {
|
|
if (_timeTrackerService) {
|
|
return _timeTrackerService;
|
|
}
|
|
_timeTrackerService = await TimeTrackerService.create(config);
|
|
return _timeTrackerService;
|
|
};
|
|
var initializeTimeTrackerServiceWithDependencies = async (dependencies) => {
|
|
if (_timeTrackerService) {
|
|
return _timeTrackerService;
|
|
}
|
|
_timeTrackerService = await TimeTrackerService.createWithDependencies(dependencies);
|
|
return _timeTrackerService;
|
|
};
|
|
|
|
// api/modules/time-tracking/routes.ts
|
|
import express from "express";
|
|
var createTimeTrackingRoutes = (deps) => {
|
|
const router = express.Router();
|
|
const { timeTrackerService } = deps;
|
|
router.get(
|
|
"/current",
|
|
asyncHandler(async (_req, res) => {
|
|
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, res) => {
|
|
const event = req.body;
|
|
await timeTrackerService.handleEvent(event);
|
|
successResponse(res, null);
|
|
})
|
|
);
|
|
router.get(
|
|
"/day/:date",
|
|
asyncHandler(async (req, res) => {
|
|
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, res) => {
|
|
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, res) => {
|
|
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, res) => {
|
|
const { year } = req.params;
|
|
const data = await timeTrackerService.getYearData(parseInt(year));
|
|
successResponse(res, data);
|
|
})
|
|
);
|
|
router.get(
|
|
"/stats",
|
|
asyncHandler(async (req, res) => {
|
|
const year = req.query.year ? parseInt(req.query.year) : void 0;
|
|
const month = req.query.month ? parseInt(req.query.month) : void 0;
|
|
const stats = await timeTrackerService.getStats(year, month);
|
|
successResponse(res, stats);
|
|
})
|
|
);
|
|
return router;
|
|
};
|
|
|
|
// api/modules/time-tracking/index.ts
|
|
var createTimeTrackingModule = (moduleConfig = {}) => {
|
|
let serviceInstance;
|
|
return createApiModule(TIME_TRACKING_MODULE, {
|
|
routes: (container) => {
|
|
const timeTrackerService = container.getSync("timeTrackerService");
|
|
return createTimeTrackingRoutes({ timeTrackerService });
|
|
},
|
|
lifecycle: {
|
|
onLoad: async (container) => {
|
|
serviceInstance = await initializeTimeTrackerService(moduleConfig.config);
|
|
container.register("timeTrackerService", () => serviceInstance);
|
|
}
|
|
}
|
|
});
|
|
};
|
|
var time_tracking_default = createTimeTrackingModule;
|
|
|
|
export {
|
|
HeartbeatService,
|
|
createHeartbeatService,
|
|
SessionPersistenceService,
|
|
createSessionPersistence,
|
|
TimeTrackerService,
|
|
getTimeTrackerService,
|
|
initializeTimeTrackerService,
|
|
initializeTimeTrackerServiceWithDependencies,
|
|
createTimeTrackingRoutes,
|
|
createTimeTrackingModule,
|
|
time_tracking_default
|
|
};
|