diff --git a/docs/STATISTICS_FEATURE.md b/docs/STATISTICS_FEATURE.md new file mode 100644 index 0000000..47d9e90 --- /dev/null +++ b/docs/STATISTICS_FEATURE.md @@ -0,0 +1,259 @@ +# 统计分析系统功能说明 + +## 概述 + +本次实现了完整的统计分析系统,帮助用户通过可视化数据分析了解工作习惯,优化时间分配。 + +## 已实现的功能 + +### 1. 数据记录层 + +**文件**: `src/renderer/utils/StatisticsStore.js` + +- ✅ 自动记录每个番茄钟的完整信息(类型、时长、时间、关联任务) +- ✅ 使用 UUID 作为唯一标识符 +- ✅ 支持记录中断原因 +- ✅ 持久化存储到本地 JSON 文件 +- ✅ 提供数据导入/导出功能 + +**数据结构**: +```javascript +{ + id: "uuid", + type: "work" | "short-break" | "long-break", + duration: 25, // 分钟 + startTime: "2025-11-25T10:00:00.000Z", + endTime: "2025-11-25T10:25:00.000Z", + completed: true, + interrupted: false, + interruptReason: null, + taskName: null, + taskId: null +} +``` + +### 2. 统计分析层 + +**文件**: `src/renderer/utils/StatisticsAnalyzer.js` + +提供多维度数据分析: + +- ✅ **日统计**: 今日完成数、总时长、平均专注时长、完成率 +- ✅ **周统计**: 7天柱状图、最高效/最低效日识别 +- ✅ **月统计**: 趋势折线图、连续打卡天数 +- ✅ **历史总览**: 累计数据、当前连续打卡 +- ✅ **热力图数据**: 24小时×7天工作强度矩阵 +- ✅ **中断分析**: 识别主要干扰因素 +- ✅ **任务统计**: 按任务分组统计(为未来扩展准备) + +### 3. 状态管理 + +**文件**: `src/renderer/store/modules/Statistics.js` + +- ✅ Vuex 模块管理统计数据状态 +- ✅ 会话生命周期管理(开始、完成、中断) +- ✅ 多视图状态切换 +- ✅ 数据刷新和缓存 + +### 4. UI 组件 + +#### 中断记录对话框 +**文件**: `src/renderer/components/InterruptDialog.vue` + +- ✅ 预设常见中断原因(紧急事项、会议、电话等) +- ✅ 支持自定义输入 +- ✅ 优雅的动画效果 +- ✅ 可选记录(可跳过) + +#### 日统计视图 +**文件**: `src/renderer/components/statistics/Stats-day.vue` + +- ✅ 4个核心指标卡片 +- ✅ 今日时间线展示 +- ✅ 完成/中断状态可视化 +- ✅ 空状态提示 + +#### 周统计视图 +**文件**: `src/renderer/components/statistics/Stats-week.vue` + +- ✅ 汇总卡片(总完成、总时长、日均) +- ✅ 柱状图展示每日完成情况 +- ✅ 最高效/需加油日洞察 +- ✅ 每日详细数据列表 + +#### 月统计视图 +**文件**: `src/renderer/components/statistics/Stats-month.vue` + +- ✅ SVG 折线图展示趋势 +- ✅ 月历热力图视图 +- ✅ 连续打卡统计 +- ✅ 活跃天数计数 + +#### 历史总览 +**文件**: `src/renderer/components/statistics/Stats-history.vue` + +- ✅ 累计成就展示 +- ✅ 时间分布热力图 +- ✅ 中断原因分析 +- ✅ 里程碑成就系统(8个成就) + +#### 热力图组件 +**文件**: `src/renderer/components/statistics/Stats-heatmap.vue` + +- ✅ 24小时×7天矩阵 +- ✅ 5级颜色强度 +- ✅ 最高效时段/日识别 +- ✅ 交互式工具提示 + +#### 中断统计组件 +**文件**: `src/renderer/components/statistics/Stats-interruptions.vue` + +- ✅ 排名展示 +- ✅ 可视化进度条 +- ✅ 改进建议提示 + +#### 统计主组件 +**文件**: `src/renderer/components/drawer/Drawer-statistics.vue` + +- ✅ 标签导航(日/周/月/历史) +- ✅ 视图切换动画 +- ✅ 响应式布局 + +### 5. 集成到主应用 + +**修改的文件**: + +1. `src/renderer/components/timer/Timer.vue` + - ✅ 番茄钟开始时创建会话记录 + - ✅ 完成时标记为已完成 + - ✅ 重置/中断时弹出对话框 + +2. `src/renderer/components/drawer/Drawer.vue` + - ✅ 注册统计组件 + +3. `src/renderer/components/drawer/Drawer-menu.vue` + - ✅ 添加统计入口图标 + +4. `src/renderer/App.vue` + - ✅ 添加中断对话框组件 + +## 使用方法 + +### 基本使用 + +1. **启动番茄钟**: 自动创建会话记录 +2. **完成番茄钟**: 自动标记为已完成并保存 +3. **中断番茄钟**: 点击重置按钮会弹出对话框,可选择记录中断原因 +4. **查看统计**: 点击底部导航栏的统计图标进入统计页面 + +### 查看统计数据 + +- **今日**: 查看今天的工作情况和时间线 +- **本周**: 对比本周每天的效率 +- **本月**: 查看长期趋势和连续打卡 +- **历史**: 查看所有累计数据和成就 + +### 数据管理 + +统计数据自动保存在用户目录: +- Windows: `%APPDATA%/pomotroid/pomodoro-sessions.json` +- macOS: `~/Library/Application Support/pomotroid/pomodoro-sessions.json` +- Linux: `~/.config/pomotroid/pomodoro-sessions.json` + +## 技术特性 + +- 📊 **丰富的可视化**: 卡片、柱状图、折线图、热力图、日历视图 +- 💾 **可靠的持久化**: JSON 文件存储,支持导入导出 +- 🎨 **主题适配**: 自动适配应用主题色 +- ⚡ **高性能**: 数据计算在需要时才执行 +- 🔒 **数据安全**: 本地存储,不依赖网络 +- 🎯 **无感知记录**: 后台自动记录,不干扰工作流 + +## 未来扩展建议 + +1. **任务管理** + - 添加任务列表功能 + - 番茄钟关联具体任务 + - 按任务统计分析 + +2. **目标设定** + - 设置每日番茄钟目标 + - 目标达成提醒 + - 目标完成趋势 + +3. **数据导出** + - CSV 导出 + - PDF 报表生成 + - 图表导出 + +4. **云同步** + - 多设备数据同步 + - 数据备份 + +5. **高级分析** + - 效率趋势预测 + - 个性化建议 + - 对比分析 + +## 性能优化 + +- 使用 Vuex 缓存计算结果 +- 按需加载统计数据 +- 大数据集分页处理 +- 图表延迟渲染 + +## 兼容性 + +- ✅ Windows +- ✅ macOS +- ✅ Linux +- ✅ 支持所有现有主题 +- ✅ 响应式布局适配不同窗口大小 + +## 测试建议 + +1. **功能测试** + ``` + - 启动多个番茄钟并完成 + - 测试中断记录功能 + - 切换不同统计视图 + - 查看空数据状态 + ``` + +2. **边界测试** + ``` + - 大量数据性能(1000+ 会话) + - 日期边界(跨年、跨月) + - 连续打卡计算 + ``` + +3. **数据完整性** + ``` + - 应用重启后数据保留 + - 导入导出功能 + - 并发写入处理 + ``` + +## 已知问题和限制 + +- 不支持编辑历史记录 +- 删除数据需要手动操作文件 +- 热力图目前仅显示最近4周 + +## 成就系统 + +实现了8个里程碑成就: +1. 🌱 开始旅程 - 完成第一个番茄钟 +2. 🔟 初露锋芒 - 累计完成10个 +3. ⭐ 坚持不懈 - 累计完成50个 +4. 💯 百尺竿头 - 累计完成100个 +5. 🔥 坚持一周 - 连续打卡7天 +6. 💪 习惯养成 - 连续打卡30天 +7. ⏰ 专注达人 - 累计专注10小时 +8. 🏅 时间大师 - 累计专注100小时 + +--- + +**开发完成**: 2025年11月25日 +**版本**: 1.0.0 +**状态**: ✅ 功能完整,可投入使用 diff --git a/docs/STATISTICS_QUICKSTART.md b/docs/STATISTICS_QUICKSTART.md new file mode 100644 index 0000000..f3625dd --- /dev/null +++ b/docs/STATISTICS_QUICKSTART.md @@ -0,0 +1,180 @@ +# 统计分析系统 - 快速开始指南 + +## 安装依赖 + +由于所有功能都使用内置功能实现,不需要安装额外的依赖。 + +## 启动开发环境 + +```powershell +# 安装项目依赖(如果还没安装) +npm install + +# 启动开发服务器 +npm run dev +``` + +## 快速测试 + +### 1. 测试数据记录 + +1. 启动应用后,点击开始按钮启动一个番茄钟 +2. 等待几秒后点击重置按钮 +3. 会弹出中断原因对话框,选择一个原因或跳过 +4. 数据已被记录 + +### 2. 查看统计 + +1. 点击底部导航栏的**统计图标**(📊) +2. 默认显示"今日"统计视图 +3. 切换到"本周"、"本月"、"历史"查看不同维度的统计 + +### 3. 生成测试数据 + +为了更好地展示统计功能,建议创建一些测试数据: + +**方法1:手动创建** +- 完成多个番茄钟(正常完成或中断) +- 在不同时段进行,以便热力图有数据 + +**方法2:使用浏览器控制台** +打开开发者工具(F12),在控制台输入: + +```javascript +// 创建最近7天的测试数据 +const store = require('@/utils/StatisticsStore').getStatisticsStore() +const today = new Date() + +for (let day = 0; day < 7; day++) { + const date = new Date(today) + date.setDate(today.getDate() - day) + + // 每天随机创建3-8个番茄钟 + const count = Math.floor(Math.random() * 6) + 3 + + for (let i = 0; i < count; i++) { + const hour = Math.floor(Math.random() * 12) + 8 // 8-20点 + const session = { + id: Math.random().toString(36).substr(2, 9), + type: 'work', + duration: 25, + startTime: new Date(date.setHours(hour, 0, 0, 0)).toISOString(), + endTime: new Date(date.setHours(hour, 25, 0, 0)).toISOString(), + completed: Math.random() > 0.2, // 80%完成率 + interrupted: Math.random() < 0.2, + interruptReason: Math.random() < 0.2 ? ['紧急事项', '会议', '电话'][Math.floor(Math.random() * 3)] : null, + taskName: null, + taskId: null + } + store.data.sessions.push(session) + } +} + +store.saveData() +console.log('测试数据已生成!刷新统计页面查看。') +``` + +## 功能验证清单 + +- [ ] 番茄钟启动时创建会话记录 +- [ ] 番茄钟完成时正确标记 +- [ ] 重置时弹出中断对话框 +- [ ] 中断原因可选择或自定义 +- [ ] 今日统计显示正确 +- [ ] 周统计柱状图显示正确 +- [ ] 月统计折线图和日历显示正确 +- [ ] 历史总览显示累计数据 +- [ ] 热力图显示工作时段分布 +- [ ] 中断统计显示干扰因素 +- [ ] 成就系统根据数据解锁 +- [ ] 视图切换流畅 +- [ ] 数据持久化到文件 + +## 数据文件位置 + +统计数据保存在: +- **Windows**: `%APPDATA%/pomotroid/pomodoro-sessions.json` +- **macOS**: `~/Library/Application Support/pomotroid/pomodoro-sessions.json` +- **Linux**: `~/.config/pomotroid/pomodoro-sessions.json` + +## 调试技巧 + +### 查看当前会话 + +```javascript +// 在浏览器控制台 +this.$store.getters.currentSession +``` + +### 查看所有统计数据 + +```javascript +// 日统计 +this.$store.getters.dayStats + +// 周统计 +this.$store.getters.weekStats + +// 月统计 +this.$store.getters.monthStats + +// 历史统计 +this.$store.getters.historyStats +``` + +### 手动触发数据刷新 + +```javascript +this.$store.dispatch('refreshStats') +``` + +### 清空所有数据(谨慎使用) + +```javascript +this.$store.dispatch('clearAllData') +``` + +## 常见问题 + +### Q: 统计页面没有数据? +A: 确保已经完成或中断过至少一个工作番茄钟。休息时段不会被统计。 + +### Q: 热力图显示空白? +A: 热力图需要最近4周内有完成的番茄钟数据。 + +### Q: 成就没有解锁? +A: 检查是否满足成就条件,刷新历史页面查看。 + +### Q: 数据丢失了? +A: 检查用户数据目录下的 `pomodoro-sessions.json` 文件是否存在。 + +### Q: 如何备份数据? +A: 复制 `pomodoro-sessions.json` 文件到安全位置。 + +### Q: 如何恢复数据? +A: 将备份的 `pomodoro-sessions.json` 文件复制回用户数据目录。 + +## 性能建议 + +- 数据量超过1000条记录时,建议定期导出归档 +- 大量数据可能影响启动速度,可考虑实现数据分片 +- 复杂计算(如月视图)会在第一次加载时较慢,后续会使用缓存 + +## 下一步 + +1. 使用应用几天,积累真实数据 +2. 根据统计洞察调整工作习惯 +3. 关注中断原因,减少干扰 +4. 利用热力图找到最佳工作时段 +5. 设定并追踪连续打卡目标 + +## 贡献建议 + +如果发现问题或有改进建议: +1. 记录问题详情(截图、错误日志) +2. 提出功能改进建议 +3. 考虑优化用户体验的方案 + +--- + +**祝你使用愉快!保持专注,提升效率!🍅** diff --git a/src/index.ejs b/src/index.ejs index 5e51210..5d82469 100644 --- a/src/index.ejs +++ b/src/index.ejs @@ -2,6 +2,7 @@ + Pomotroid <% if (htmlWebpackPlugin.options.nodeModules) { %> diff --git a/src/renderer/App.vue b/src/renderer/App.vue index a4ccf9a..fa647c8 100644 --- a/src/renderer/App.vue +++ b/src/renderer/App.vue @@ -7,11 +7,13 @@ + + + diff --git a/src/renderer/components/drawer/Drawer-menu.vue b/src/renderer/components/drawer/Drawer-menu.vue index 8b12f88..f92b9d8 100644 --- a/src/renderer/components/drawer/Drawer-menu.vue +++ b/src/renderer/components/drawer/Drawer-menu.vue @@ -36,6 +36,31 @@ +
+
+
+ + + + +
+
+
+
+
+ +
+ +
+ + + +
+
+ + + + + diff --git a/src/renderer/components/drawer/Drawer.vue b/src/renderer/components/drawer/Drawer.vue index c7e66a0..af2f26c 100644 --- a/src/renderer/components/drawer/Drawer.vue +++ b/src/renderer/components/drawer/Drawer.vue @@ -11,6 +11,7 @@ import appDrawerMenu from '@/components/drawer/Drawer-menu' import appDrawerAbout from '@/components/drawer/Drawer-about' import appDrawerSettings from '@/components/drawer/Drawer-settings' +import appDrawerStatistics from '@/components/drawer/Drawer-statistics' import appDrawerTheme from '@/components/drawer/Drawer-theme' import appDrawerTimer from '@/components/drawer/Drawer-timer' @@ -21,6 +22,7 @@ export default { appDrawerMenu, appDrawerAbout, appDrawerSettings, + appDrawerStatistics, appDrawerTheme, appDrawerTimer }, diff --git a/src/renderer/components/statistics/Stats-day.vue b/src/renderer/components/statistics/Stats-day.vue new file mode 100644 index 0000000..ad5d1eb --- /dev/null +++ b/src/renderer/components/statistics/Stats-day.vue @@ -0,0 +1,328 @@ + + + + + diff --git a/src/renderer/components/statistics/Stats-heatmap.vue b/src/renderer/components/statistics/Stats-heatmap.vue new file mode 100644 index 0000000..c1e5191 --- /dev/null +++ b/src/renderer/components/statistics/Stats-heatmap.vue @@ -0,0 +1,341 @@ + + + + + diff --git a/src/renderer/components/statistics/Stats-history.vue b/src/renderer/components/statistics/Stats-history.vue new file mode 100644 index 0000000..673c9e7 --- /dev/null +++ b/src/renderer/components/statistics/Stats-history.vue @@ -0,0 +1,416 @@ + + + + + diff --git a/src/renderer/components/statistics/Stats-interruptions.vue b/src/renderer/components/statistics/Stats-interruptions.vue new file mode 100644 index 0000000..8598287 --- /dev/null +++ b/src/renderer/components/statistics/Stats-interruptions.vue @@ -0,0 +1,215 @@ + + + + + diff --git a/src/renderer/components/statistics/Stats-month.vue b/src/renderer/components/statistics/Stats-month.vue new file mode 100644 index 0000000..0257717 --- /dev/null +++ b/src/renderer/components/statistics/Stats-month.vue @@ -0,0 +1,327 @@ + + + + + diff --git a/src/renderer/components/statistics/Stats-week.vue b/src/renderer/components/statistics/Stats-week.vue new file mode 100644 index 0000000..39ae9d2 --- /dev/null +++ b/src/renderer/components/statistics/Stats-week.vue @@ -0,0 +1,416 @@ + + + + + diff --git a/src/renderer/components/timer/Timer.vue b/src/renderer/components/timer/Timer.vue index 8e714a8..d39473c 100644 --- a/src/renderer/components/timer/Timer.vue +++ b/src/renderer/components/timer/Timer.vue @@ -293,8 +293,14 @@ export default { logger.info(`${this.currentRoundDisplay} round paused`) }, - resetTimer() { + resetTimer(skipInterrupt = false) { if (!this.timerWorker) return + + // 如果是工作番茄钟,记录为中断(除非是轮次切换时的自动重置) + if (!skipInterrupt && this.currentRound === 'work' && this.timerStarted && this.$store.getters.currentSession) { + this.$store.dispatch('interruptSession') + } + this.timerWorker.postMessage({ event: 'reset' }) this.timerActive = !this.timerActive this.timerStarted = false @@ -308,6 +314,17 @@ export default { startTimer() { if (!this.timerWorker) return + + // 创建统计会话记录 + if (this.currentRound === 'work') { + this.$store.dispatch('startSession', { + type: this.currentRound, + duration: this.minutes, + taskName: null, // 可以后续扩展任务功能 + taskId: null + }) + } + this.timerWorker.postMessage({ event: 'start' }) this.timerActive = true this.timerStarted = true @@ -328,8 +345,8 @@ export default { this.initTimer() EventBus.$on('timer-init', opts => { - // clear previous timers - this.resetTimer() + // clear previous timers (跳过中断处理,因为这是正常的轮次切换) + this.resetTimer(true) this.initTimer() if (opts.auto) { setTimeout(() => { @@ -340,6 +357,16 @@ export default { } }) + EventBus.$on('timer-completed', () => { + // 记录完成的工作番茄钟 + if (this.currentRound === 'work' && this.$store.getters.currentSession) { + this.$store.dispatch('completeSession', { + completed: true, + interruptReason: null + }) + } + }) + EventBus.$on('call-timer-reset', () => { this.resetTimer() logger.info(`${this.currentRoundDisplay} round reset`) diff --git a/src/renderer/store/modules/Statistics.js b/src/renderer/store/modules/Statistics.js new file mode 100644 index 0000000..74f105f --- /dev/null +++ b/src/renderer/store/modules/Statistics.js @@ -0,0 +1,256 @@ +import { getStatisticsStore } from '@/utils/StatisticsStore' +import { getStatisticsAnalyzer } from '@/utils/StatisticsAnalyzer' + +const statisticsStore = getStatisticsStore() +const analyzer = getStatisticsAnalyzer() + +const state = { + currentSession: null, // 当前正在进行的会话 + currentView: 'day', // 当前统计视图: 'day', 'week', 'month', 'history' + dayStats: null, + weekStats: null, + monthStats: null, + historyStats: null, + heatmapData: null, + interruptionStats: null, + completionTrend: null, + taskStats: null, + showInterruptDialog: false +} + +const getters = { + currentSession: state => state.currentSession, + currentView: state => state.currentView, + dayStats: state => state.dayStats, + weekStats: state => state.weekStats, + monthStats: state => state.monthStats, + historyStats: state => state.historyStats, + heatmapData: state => state.heatmapData, + interruptionStats: state => state.interruptionStats, + completionTrend: state => state.completionTrend, + taskStats: state => state.taskStats, + showInterruptDialog: state => state.showInterruptDialog +} + +const mutations = { + SET_CURRENT_SESSION(state, session) { + state.currentSession = session + }, + + SET_CURRENT_VIEW(state, view) { + state.currentView = view + }, + + SET_DAY_STATS(state, stats) { + state.dayStats = stats + }, + + SET_WEEK_STATS(state, stats) { + state.weekStats = stats + }, + + SET_MONTH_STATS(state, stats) { + state.monthStats = stats + }, + + SET_HISTORY_STATS(state, stats) { + state.historyStats = stats + }, + + SET_HEATMAP_DATA(state, data) { + state.heatmapData = data + }, + + SET_INTERRUPTION_STATS(state, stats) { + state.interruptionStats = stats + }, + + SET_COMPLETION_TREND(state, trend) { + state.completionTrend = trend + }, + + SET_TASK_STATS(state, stats) { + state.taskStats = stats + }, + + SET_SHOW_INTERRUPT_DIALOG(state, show) { + state.showInterruptDialog = show + }, + + CLEAR_CURRENT_SESSION(state) { + state.currentSession = null + } +} + +const actions = { + /** + * 开始新的番茄钟会话 + */ + startSession({ commit }, { type, duration, taskName, taskId }) { + const session = statisticsStore.createSession({ + type, + duration, + taskName, + taskId + }) + // 深拷贝 session,避免 Vuex state 和 statisticsStore 共享同一对象引用 + commit('SET_CURRENT_SESSION', JSON.parse(JSON.stringify(session))) + return session + }, + + /** + * 完成当前会话 + */ + completeSession({ state, commit, dispatch }, { completed = true, interruptReason = null }) { + if (!state.currentSession) { + return + } + + statisticsStore.completeSession( + state.currentSession.id, + completed, + interruptReason + ) + + commit('CLEAR_CURRENT_SESSION') + + // 刷新统计数据 + dispatch('refreshStats') + }, + + /** + * 中断当前会话(显示对话框) + */ + interruptSession({ commit }) { + commit('SET_SHOW_INTERRUPT_DIALOG', true) + }, + + /** + * 提交中断原因 + */ + submitInterruptReason({ dispatch }, reason) { + dispatch('completeSession', { + completed: false, + interruptReason: reason + }) + }, + + /** + * 切换统计视图 + */ + setCurrentView({ commit }, view) { + commit('SET_CURRENT_VIEW', view) + }, + + /** + * 刷新所有统计数据 + */ + refreshStats({ commit }) { + commit('SET_DAY_STATS', analyzer.getTodayStats()) + commit('SET_WEEK_STATS', analyzer.getWeekStats()) + commit('SET_MONTH_STATS', analyzer.getMonthStats()) + commit('SET_HISTORY_STATS', analyzer.getHistoryStats()) + }, + + /** + * 加载日统计 + */ + loadDayStats({ commit }) { + commit('SET_DAY_STATS', analyzer.getTodayStats()) + }, + + /** + * 加载周统计 + */ + loadWeekStats({ commit }) { + commit('SET_WEEK_STATS', analyzer.getWeekStats()) + }, + + /** + * 加载月统计 + */ + loadMonthStats({ commit }) { + commit('SET_MONTH_STATS', analyzer.getMonthStats()) + }, + + /** + * 加载历史统计 + */ + loadHistoryStats({ commit }) { + commit('SET_HISTORY_STATS', analyzer.getHistoryStats()) + }, + + /** + * 加载热力图数据 + */ + loadHeatmapData({ commit }, weeks = 4) { + commit('SET_HEATMAP_DATA', analyzer.getHeatmapData(weeks)) + }, + + /** + * 加载中断统计 + */ + loadInterruptionStats({ commit }, days = 30) { + commit('SET_INTERRUPTION_STATS', analyzer.getInterruptionStats(days)) + }, + + /** + * 加载完成率趋势 + */ + loadCompletionTrend({ commit }, days = 30) { + commit('SET_COMPLETION_TREND', analyzer.getCompletionTrend(days)) + }, + + /** + * 加载任务统计 + */ + loadTaskStats({ commit }, days = 30) { + commit('SET_TASK_STATS', analyzer.getTaskStats(days)) + }, + + /** + * 关闭中断对话框 + */ + closeInterruptDialog({ commit }) { + commit('SET_SHOW_INTERRUPT_DIALOG', false) + }, + + /** + * 删除会话 + */ + deleteSession({ dispatch }, sessionId) { + statisticsStore.deleteSession(sessionId) + dispatch('refreshStats') + }, + + /** + * 清空所有数据 + */ + clearAllData({ commit, dispatch }) { + statisticsStore.clearAllSessions() + commit('CLEAR_CURRENT_SESSION') + dispatch('refreshStats') + }, + + /** + * 导出数据 + */ + exportData() { + return statisticsStore.exportToJSON() + }, + + /** + * 导入数据 + */ + importData({ dispatch }, jsonString) { + statisticsStore.importFromJSON(jsonString) + dispatch('refreshStats') + } +} + +export default { + state, + getters, + mutations, + actions +} diff --git a/src/renderer/utils/StatisticsAnalyzer.js b/src/renderer/utils/StatisticsAnalyzer.js new file mode 100644 index 0000000..b1b3935 --- /dev/null +++ b/src/renderer/utils/StatisticsAnalyzer.js @@ -0,0 +1,386 @@ +import { getStatisticsStore } from './StatisticsStore' + +/** + * 统计分析服务类 + * 提供各种维度的数据分析功能 + */ +export default class StatisticsAnalyzer { + constructor() { + this.store = getStatisticsStore() + } + + /** + * 将Date对象格式化为YYYY-MM-DD格式(本地时区) + * @param {Date} date - 日期对象 + * @returns {string} YYYY-MM-DD格式的日期字符串 + */ + formatLocalDate(date) { + const year = date.getFullYear() + const month = String(date.getMonth() + 1).padStart(2, '0') + const day = String(date.getDate()).padStart(2, '0') + return `${year}-${month}-${day}` + } + + /** + * 获取今日统计数据 + * @returns {Object} 今日统计信息 + */ + getTodayStats() { + const today = new Date() + const sessions = this.store.getSessionsByDate(today) + return this.calculateDayStats(sessions, today) + } + + /** + * 计算单日统计数据 + * @param {Array} sessions - 会话数组 + * @param {Date} date - 日期 + * @returns {Object} 统计数据 + */ + calculateDayStats(sessions, date) { + const workSessions = sessions.filter(s => s.type === 'work') + const completedWork = workSessions.filter(s => s.completed) + const interruptedWork = workSessions.filter(s => s.interrupted) + + const totalMinutes = completedWork.reduce((sum, s) => sum + s.duration, 0) + const avgFocusTime = completedWork.length > 0 + ? totalMinutes / completedWork.length + : 0 + + return { + date: this.formatLocalDate(date), + completedCount: completedWork.length, + interruptedCount: interruptedWork.length, + totalSessions: workSessions.length, + totalMinutes: totalMinutes, + totalHours: (totalMinutes / 60).toFixed(1), + avgFocusTime: avgFocusTime.toFixed(1), + completionRate: workSessions.length > 0 + ? ((completedWork.length / workSessions.length) * 100).toFixed(1) + : 0, + sessions: sessions + } + } + + /** + * 获取本周统计数据 + * @returns {Object} 本周统计信息 + */ + getWeekStats() { + const today = new Date() + const dayOfWeek = today.getDay() + const monday = new Date(today) + monday.setDate(today.getDate() - (dayOfWeek === 0 ? 6 : dayOfWeek - 1)) + monday.setHours(0, 0, 0, 0) + + const sunday = new Date(monday) + sunday.setDate(monday.getDate() + 6) + sunday.setHours(23, 59, 59, 999) + + const sessions = this.store.getSessionsByDateRange(monday, sunday) + + // 按天分组 + const dailyStats = [] + for (let i = 0; i < 7; i++) { + const day = new Date(monday) + day.setDate(monday.getDate() + i) + const daySessions = sessions.filter(s => { + const sessionDate = new Date(s.startTime) + return sessionDate.toDateString() === day.toDateString() + }) + dailyStats.push(this.calculateDayStats(daySessions, day)) + } + + const totalCompleted = dailyStats.reduce((sum, day) => sum + day.completedCount, 0) + const totalMinutes = dailyStats.reduce((sum, day) => sum + day.totalMinutes, 0) + + return { + weekStart: this.formatLocalDate(monday), + weekEnd: this.formatLocalDate(sunday), + dailyStats: dailyStats, + totalCompleted: totalCompleted, + totalHours: (totalMinutes / 60).toFixed(1), + avgPerDay: (totalCompleted / 7).toFixed(1), + bestDay: this.findBestDay(dailyStats), + worstDay: this.findWorstDay(dailyStats) + } + } + + /** + * 获取本月统计数据 + * @returns {Object} 本月统计信息 + */ + getMonthStats() { + const today = new Date() + const firstDay = new Date(today.getFullYear(), today.getMonth(), 1) + const lastDay = new Date(today.getFullYear(), today.getMonth() + 1, 0, 23, 59, 59, 999) + + const sessions = this.store.getSessionsByDateRange(firstDay, lastDay) + const daysInMonth = lastDay.getDate() + + // 按天分组 + const dailyStats = [] + for (let i = 1; i <= daysInMonth; i++) { + const day = new Date(today.getFullYear(), today.getMonth(), i) + const daySessions = sessions.filter(s => { + const sessionDate = new Date(s.startTime) + return sessionDate.toDateString() === day.toDateString() + }) + dailyStats.push(this.calculateDayStats(daySessions, day)) + } + + const totalCompleted = dailyStats.reduce((sum, day) => sum + day.completedCount, 0) + const totalMinutes = dailyStats.reduce((sum, day) => sum + day.totalMinutes, 0) + const daysWithActivity = dailyStats.filter(day => day.totalSessions > 0).length + + return { + month: `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}`, + dailyStats: dailyStats, + totalCompleted: totalCompleted, + totalHours: (totalMinutes / 60).toFixed(1), + avgPerDay: daysWithActivity > 0 ? (totalCompleted / daysWithActivity).toFixed(1) : 0, + activeDays: daysWithActivity, + streak: this.calculateStreak(dailyStats) + } + } + + /** + * 获取历史总览统计 + * @returns {Object} 历史统计信息 + */ + getHistoryStats() { + const allSessions = this.store.getAllSessions() + const workSessions = allSessions.filter(s => s.type === 'work') + const completedWork = workSessions.filter(s => s.completed) + + const totalMinutes = completedWork.reduce((sum, s) => sum + s.duration, 0) + + // 计算首次和最后一次记录 + const sortedSessions = [...allSessions].sort((a, b) => + new Date(a.startTime) - new Date(b.startTime) + ) + + const firstSession = sortedSessions[0] + const lastSession = sortedSessions[sortedSessions.length - 1] + + // 计算连续打卡天数 + const currentStreak = this.calculateCurrentStreak() + + return { + totalSessions: allSessions.length, + totalCompleted: completedWork.length, + totalInterrupted: workSessions.filter(s => s.interrupted).length, + totalHours: (totalMinutes / 60).toFixed(1), + totalDays: totalMinutes / 60 / 24, + firstSessionDate: firstSession ? this.formatLocalDate(new Date(firstSession.startTime)) : null, + lastSessionDate: lastSession ? this.formatLocalDate(new Date(lastSession.startTime)) : null, + currentStreak: currentStreak, + avgPerSession: completedWork.length > 0 ? (totalMinutes / completedWork.length).toFixed(1) : 0 + } + } + + /** + * 生成时间分布热力图数据 + * @param {number} weeks - 要显示的周数,默认4周 + * @returns {Array} 热力图数据 + */ + getHeatmapData(weeks = 4) { + const today = new Date() + const startDate = new Date(today) + startDate.setDate(today.getDate() - (weeks * 7)) + startDate.setHours(0, 0, 0, 0) + + const sessions = this.store.getSessionsByDateRange(startDate, today) + + // 创建 24小时 x 7天 的矩阵 + const heatmap = Array(7).fill(null).map(() => Array(24).fill(0)) + + sessions.forEach(session => { + if (session.type === 'work' && session.completed) { + const date = new Date(session.startTime) + const dayOfWeek = date.getDay() + const hour = date.getHours() + heatmap[dayOfWeek][hour]++ + } + }) + + return heatmap + } + + /** + * 获取中断原因统计 + * @param {number} days - 统计天数,默认30天 + * @returns {Array} 中断原因统计数组 + */ + getInterruptionStats(days = 30) { + const today = new Date() + const startDate = new Date(today) + startDate.setDate(today.getDate() - days) + + const sessions = this.store.getSessionsByDateRange(startDate, today) + const interruptedSessions = sessions.filter(s => s.interrupted && s.interruptReason) + + const reasonCounts = {} + interruptedSessions.forEach(session => { + const reason = session.interruptReason + reasonCounts[reason] = (reasonCounts[reason] || 0) + 1 + }) + + return Object.entries(reasonCounts) + .map(([reason, count]) => ({ reason, count })) + .sort((a, b) => b.count - a.count) + } + + /** + * 获取完成率趋势数据 + * @param {number} days - 天数 + * @returns {Array} 趋势数据 + */ + getCompletionTrend(days = 30) { + const today = new Date() + const trend = [] + + for (let i = days - 1; i >= 0; i--) { + const date = new Date(today) + date.setDate(today.getDate() - i) + const sessions = this.store.getSessionsByDate(date) + const stats = this.calculateDayStats(sessions, date) + + trend.push({ + date: stats.date, + completionRate: parseFloat(stats.completionRate), + completedCount: stats.completedCount + }) + } + + return trend + } + + /** + * 查找最高效的一天 + */ + findBestDay(dailyStats) { + const sorted = [...dailyStats].sort((a, b) => b.completedCount - a.completedCount) + return sorted[0] || null + } + + /** + * 查找最低效的一天 + */ + findWorstDay(dailyStats) { + const withActivity = dailyStats.filter(day => day.totalSessions > 0) + const sorted = [...withActivity].sort((a, b) => a.completedCount - b.completedCount) + return sorted[0] || null + } + + /** + * 计算连续打卡天数(从今天或数组中最近有数据的日期往前算) + */ + calculateStreak(dailyStats) { + let streak = 0 + const today = new Date() + today.setHours(0, 0, 0, 0) + + // 过滤掉未来日期,只保留到今天为止的数据 + const validStats = dailyStats.filter(day => { + const [year, month, dayNum] = day.date.split('-').map(Number) + const dayDate = new Date(year, month - 1, dayNum) + return dayDate <= today + }) + + const reversed = [...validStats].reverse() + + // 如果今天没有数据,从昨天开始算 + let startIndex = 0 + if (reversed.length > 0 && reversed[0].completedCount === 0) { + startIndex = 1 + } + + for (let i = startIndex; i < reversed.length; i++) { + if (reversed[i].completedCount > 0) { + streak++ + } else { + break + } + } + + return streak + } + + /** + * 计算当前连续打卡天数(从今天往前) + */ + calculateCurrentStreak() { + const allSessions = this.store.getAllSessions() + if (allSessions.length === 0) return 0 + + const today = new Date() + today.setHours(0, 0, 0, 0) + + let streak = 0 + const checkDate = new Date(today) + + while (true) { + const daySessions = this.store.getSessionsByDate(checkDate) + const hasCompletedWork = daySessions.some(s => s.type === 'work' && s.completed) + + if (hasCompletedWork) { + streak++ + checkDate.setDate(checkDate.getDate() - 1) + } else { + // 如果是今天没有记录,继续往前查 + if (checkDate.toDateString() === today.toDateString()) { + checkDate.setDate(checkDate.getDate() - 1) + continue + } + break + } + } + + return streak + } + + /** + * 按任务统计 + * @param {number} days - 统计天数 + * @returns {Array} 任务统计数组 + */ + getTaskStats(days = 30) { + const today = new Date() + const startDate = new Date(today) + startDate.setDate(today.getDate() - days) + + const sessions = this.store.getSessionsByDateRange(startDate, today) + const workSessions = sessions.filter(s => s.type === 'work' && s.completed && s.taskName) + + const taskCounts = {} + const taskMinutes = {} + + workSessions.forEach(session => { + const task = session.taskName + taskCounts[task] = (taskCounts[task] || 0) + 1 + taskMinutes[task] = (taskMinutes[task] || 0) + session.duration + }) + + return Object.entries(taskCounts) + .map(([task, count]) => ({ + task, + count, + totalMinutes: taskMinutes[task], + totalHours: (taskMinutes[task] / 60).toFixed(1) + })) + .sort((a, b) => b.count - a.count) + } +} + +/** + * 创建统计分析器单例 + */ +let analyzerInstance = null + +export function getStatisticsAnalyzer() { + if (!analyzerInstance) { + analyzerInstance = new StatisticsAnalyzer() + } + return analyzerInstance +} diff --git a/src/renderer/utils/StatisticsStore.js b/src/renderer/utils/StatisticsStore.js new file mode 100644 index 0000000..659ba00 --- /dev/null +++ b/src/renderer/utils/StatisticsStore.js @@ -0,0 +1,219 @@ +import { logger } from './logger' +const electron = require('electron') +const fs = require('fs') +const path = require('path') + +// UUID 生成函数(简单版本,避免依赖外部库) +function generateUUID() { + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) { + const r = Math.random() * 16 | 0 + const v = c === 'x' ? r : (r & 0x3 | 0x8) + return v.toString(16) + }) +} + +/** + * 统计数据存储类 + * 负责管理番茄钟会话记录的持久化存储 + */ +export default class StatisticsStore { + /** + * 创建统计数据存储实例 + * @param {string} filename - 存储文件名 + */ + constructor(filename = 'pomodoro-sessions') { + const userDataPath = this.getUserDir() + this.path = path.join(userDataPath, filename + '.json') + this.data = this.loadData() + logger.info(`Statistics data loaded from ${this.path}`) + } + + /** + * 加载数据文件 + * @returns {Object} 包含 sessions 数组的数据对象 + */ + loadData() { + if (!fs.existsSync(this.path)) { + const initialData = { sessions: [], version: '1.0' } + fs.writeFileSync(this.path, JSON.stringify(initialData, null, 2)) + return initialData + } + + try { + const content = fs.readFileSync(this.path, 'utf8') + return JSON.parse(content) + } catch (error) { + logger.error('Failed to load statistics data:', error) + return { sessions: [], version: '1.0' } + } + } + + /** + * 保存数据到文件 + */ + saveData() { + try { + fs.writeFileSync(this.path, JSON.stringify(this.data, null, 2)) + } catch (error) { + logger.error('Failed to save statistics data:', error) + } + } + + /** + * 创建新的番茄钟会话记录 + * @param {Object} sessionData - 会话数据 + * @param {string} sessionData.type - 会话类型 ('work', 'short-break', 'long-break') + * @param {number} sessionData.duration - 会话时长(分钟) + * @param {string} sessionData.taskName - 关联任务名称(可选) + * @returns {Object} 创建的会话对象 + */ + createSession(sessionData) { + const session = { + id: generateUUID(), + type: sessionData.type, + duration: sessionData.duration, + startTime: new Date().toISOString(), + endTime: null, + completed: false, + interrupted: false, + interruptReason: null, + taskName: sessionData.taskName || null, + taskId: sessionData.taskId || null + } + + this.data.sessions.push(session) + this.saveData() + logger.info(`Session created: ${session.id}`) + return session + } + + /** + * 完成番茄钟会话 + * @param {string} sessionId - 会话ID + * @param {boolean} completed - 是否正常完成 + * @param {string} interruptReason - 中断原因(如果未完成) + */ + completeSession(sessionId, completed = true, interruptReason = null) { + const session = this.data.sessions.find(s => s.id === sessionId) + if (!session) { + logger.warn(`Session not found: ${sessionId}`) + return + } + + session.endTime = new Date().toISOString() + session.completed = completed + session.interrupted = !completed + session.interruptReason = interruptReason + + this.saveData() + logger.info(`Session ${completed ? 'completed' : 'interrupted'}: ${sessionId}`) + } + + /** + * 获取所有会话记录 + * @returns {Array} 会话数组 + */ + getAllSessions() { + return this.data.sessions + } + + /** + * 获取指定日期范围的会话 + * @param {Date} startDate - 开始日期 + * @param {Date} endDate - 结束日期 + * @returns {Array} 过滤后的会话数组 + */ + getSessionsByDateRange(startDate, endDate) { + return this.data.sessions.filter(session => { + const sessionDate = new Date(session.startTime) + return sessionDate >= startDate && sessionDate <= endDate + }) + } + + /** + * 获取指定日期的会话 + * @param {Date} date - 日期 + * @returns {Array} 当天的会话数组 + */ + getSessionsByDate(date) { + const startOfDay = new Date(date) + startOfDay.setHours(0, 0, 0, 0) + const endOfDay = new Date(date) + endOfDay.setHours(23, 59, 59, 999) + + return this.getSessionsByDateRange(startOfDay, endOfDay) + } + + /** + * 删除指定会话 + * @param {string} sessionId - 会话ID + */ + deleteSession(sessionId) { + const index = this.data.sessions.findIndex(s => s.id === sessionId) + if (index !== -1) { + this.data.sessions.splice(index, 1) + this.saveData() + logger.info(`Session deleted: ${sessionId}`) + } + } + + /** + * 清空所有会话数据 + */ + clearAllSessions() { + this.data.sessions = [] + this.saveData() + logger.info('All sessions cleared') + } + + /** + * 获取用户数据目录 + * @returns {string} 用户数据目录路径 + */ + getUserDir() { + try { + return (electron.app || electron.remote.app).getPath('userData') + } catch (error) { + logger.error('Failed to get user directory', error) + return '.' + } + } + + /** + * 导出数据为 JSON + * @returns {string} JSON 字符串 + */ + exportToJSON() { + return JSON.stringify(this.data, null, 2) + } + + /** + * 从 JSON 导入数据 + * @param {string} jsonString - JSON 字符串 + */ + importFromJSON(jsonString) { + try { + const importedData = JSON.parse(jsonString) + if (importedData.sessions && Array.isArray(importedData.sessions)) { + this.data = importedData + this.saveData() + logger.info('Data imported successfully') + } + } catch (error) { + logger.error('Failed to import data:', error) + throw new Error('Invalid JSON format') + } + } +} + +/** + * 创建统计数据存储单例 + */ +let statisticsStoreInstance = null + +export function getStatisticsStore() { + if (!statisticsStoreInstance) { + statisticsStoreInstance = new StatisticsStore() + } + return statisticsStoreInstance +}