feat: 新增统计视图(ADR-012)
- 新增 /stats 路由与导航入口,与看板、今日视图平级 - 完成热力图:近一年 53 周,按每天完成数固定阈值分五档着色 - 汇总指标:今天 / 连续 / 本月 / 累计;连续天数允许「今天还没开始」 - 「时间花在哪」按标签分布(多标签任务每个标签各计一次) - 「一些细节」:完成周期中位数 / 最长连续 / 有记录天数 / 平均每天 - 统计只从 completedAt 推算,不新增计数表,数字随删除偏少但永不自相矛盾 热力图的布局约束记在 ADR-012 结论 9: - 月份标签绝对定位,避免 whitespace-nowrap 撑宽列而多出横向滚动条 - 富余宽度交给列间隙(justify-between),格子保持 11×11 正方 - 右侧补与星期标签列等宽的留白,使左右对称并给 ring 描边留出余量 新增 utils/activity.ts(纯函数)、views/StatsView.vue 及两个 spec、e2e/stats.spec.ts; 同步更新 README、docs/adr.md 与 AppNav 的三入口断言。
This commit is contained in:
@@ -13,6 +13,7 @@
|
||||
- **标签管理**:全局重命名、合并、删除标签,不必逐个任务改
|
||||
- **标签颜色**:8 色色板,设过颜色的标签在卡片与筛选下拉里带一个圆点;重命名 / 合并时颜色自动跟随
|
||||
- **今日视图**:按紧急度分组汇总(逾期 / 今天到期 / 进行中 / 待办),含本周完成等统计
|
||||
- **统计视图**:近一年完成热力图(按当天完成数分五档着色)、连续完成天数、本月与累计完成,附带标签分布与完成周期
|
||||
- 键盘快捷键;任务卡片可用方向键换列与调序
|
||||
- **深色模式**:跟随系统 / 浅色 / 深色三态
|
||||
- 导出导入 JSON(**覆盖 / 合并**两种模式)+ 启动自动备份;覆盖或合并前都会自动留档
|
||||
@@ -112,14 +113,15 @@ npm run dev
|
||||
|
||||
---
|
||||
|
||||
## 两个视图
|
||||
## 三个视图
|
||||
|
||||
顶部导航可在两个视图之间切换:
|
||||
顶部导航可在三个视图之间切换:
|
||||
|
||||
| 视图 | 路径 | 组织方式 |
|
||||
| ---- | -------- | ----------------------------------------------- |
|
||||
| 看板 | `/` | 按**状态**:待办 / 进行中 / 已完成 |
|
||||
| 今日 | `/today` | 按**紧急度**:已逾期 → 今天到期 → 进行中 → 待办 |
|
||||
| 统计 | `/stats` | 按**时间**:近一年的每日完成量 |
|
||||
|
||||
今日视图顶部有四张统计卡片(逾期 / 今天到期 / 进行中 / 本周完成),下方是可直接勾选完成的待办列表。
|
||||
|
||||
@@ -127,6 +129,24 @@ npm run dev
|
||||
|
||||
只要任务未完成且未归档,就一定会出现在某个分组里 —— 包括没设截止日期的待办(归入「待办」组)。
|
||||
|
||||
### 统计视图
|
||||
|
||||
今日视图往前看(今天该做什么),统计视图往回看(这些天做过什么)。
|
||||
|
||||
- **顶部四张卡片**:今天完成 / 连续完成天数 / 本月完成 / 累计完成
|
||||
- **完成热力图**:近一年(53 周),一列一周、一行一天,颜色分五档(0 / 1 / 2 / 3 / 4 个及以上)。悬停任意格子可看当天日期与完成数;今天那格带一圈描边
|
||||
- **最忙的一天**:完成数最多的一天(并列时取最近的)
|
||||
- **时间花在哪**:已完成任务按标签分布(前 6 项,条形长度按最大项归一)。一个任务带多个标签时每个标签各计一次,没有标签的归入「未分类」
|
||||
- **一些细节**:平均完成周期(中位数)/ 最长连续 / 有记录天数 / 平均每天
|
||||
|
||||
「连续完成」允许今天还没开始:只要昨天有记录就继续算,不会在零点一过就把成绩清零。
|
||||
|
||||
平均完成周期取**中位数**而不是平均值:一条躺了半年的任务就能把平均值拉偏到没法看,中位数描述的是「通常要多久」。不足一天显示「当天」。
|
||||
|
||||
「平均每天」按**有记录的天数**算,不是按自然日 —— 否则周末不干活会被算成效率下降,而这只是没上班。
|
||||
|
||||
热力图只根据任务上的 `completedAt` 推算,**不额外维护计数表**。代价是永久删除或清空回收站会连同那天的记录一起消失,所以这里的数字通常比实际做过的偏少 —— 换来的是「数字永远和任务对得上」,不会出现恢复一条任务后计数却对不上的情况。
|
||||
|
||||
---
|
||||
|
||||
## 安装与离线(PWA)
|
||||
@@ -358,7 +378,9 @@ PLAYWRIGHT_CHANNEL=chromium npm run test:e2e # 用自带 Chromium(需先 npx
|
||||
- 「已完成」列固定按完成时间倒序,不支持手动调序(拖拽与方向键只能跨列)。
|
||||
- 归档阈值固定为「已完成超过 7 天」,暂不可配置。
|
||||
- 今日视图的分组规则固定(逾期 > 今天到期 > 进行中 > 待办),不可自定义。
|
||||
- 统计只覆盖本周完成数等基础指标,没有趋势图或历史留存。
|
||||
- 统计只根据任务上的完成时间推算,永久删除或清空回收站会带走相应记录,因此数字比实际偏少。
|
||||
- 统计没有按优先级拆分,也不支持自定义时间范围。
|
||||
- 统计的标签分布按「每个标签各计一次」汇总,因此各项之和会大于任务数(多标签任务对多个方向都算贡献)。
|
||||
- 深色模式只跟随系统或手动三态切换,没有按时间段自动切换。
|
||||
- 回收站固定保留 30 天,没有搜索 / 筛选,只有「按删除时间」一种顺序。
|
||||
- 撤销栈只保留最近 50 步,且仅存在于当前会话与当前标签页:刷新或换标签页即清空,不跨设备同步;另一标签页改动数据后,本页的历史会作废。
|
||||
|
||||
+93
@@ -382,3 +382,96 @@ ADR-007 明确否决了「批量改属性做 `[{id, before}]` 快照式撤销」
|
||||
- 标签圆点带 `data-role="tag-dot"`:卡片上本来就有别的圆点元素(状态点、勾选框),
|
||||
用 `.rounded-full` 这类样式选择器会误伤,单测与 e2e 一律按 `data-role` 定位。
|
||||
- 导出 JSON 不含颜色,README 的已知限制已写明。
|
||||
|
||||
---
|
||||
|
||||
## ADR-012 · 统计视图:完成趋势与热力图
|
||||
|
||||
**背景**
|
||||
|
||||
README 的「已知限制」里长期挂着一条「统计只覆盖本周完成数等基础指标,没有趋势图或历史留存」。
|
||||
而 `completedAt` 从 T03 起就在数据里,除了算「本周完成」和判断归档候选之外没有任何出口 ——
|
||||
用户想知道「我这几周到底做了多少」,只能导出 JSON 自己数。
|
||||
|
||||
ADR-005 在引入路由时就写明:「若未来增加今日视图、统计等真正的独立页面,再引入路由」。
|
||||
统计视图正是那句话里的第二个页面。
|
||||
|
||||
**结论**
|
||||
|
||||
1. **独立路由 `/stats`**,与今日视图平级。今日视图往前看(今天该做什么),统计往回看(做过什么),
|
||||
两者的信息密度和使用时机都不同,塞在同一屏会互相稀释。按 ADR-005 的既有惯例懒加载。
|
||||
2. **数据只从 `completedAt` 推算**,不引入「每日完成计数」表。按本地日期分组,
|
||||
逐条走 `localDateOf` —— `completedAt` 存的是 UTC ISO,直接截前 10 位在东八区会把晚上完成的任务算到前一天。
|
||||
3. **热力图 53 周(一年)**,列为一周、行为周一到周日(周一为一周之始,与 `utils/stats.ts` 口径一致)。
|
||||
先用 26 周试过:只占卡片一半宽,右侧大片留白,且看不出季节性起伏。
|
||||
4. **强度固定阈值分档**(0 / 1 / 2 / 3 / 4+)。按最大值相对分档会让数据一变多就所有格子一起变深,
|
||||
反而看不出「哪天特别猛」;固定阈值可预测,也便于测试。
|
||||
5. **月份标签只打在该月第一周**(周一落在 1–7 号)。最初用「月份变了就打」,结果 4 月与 5 月
|
||||
这种相邻两周各打一个,而每列只有 14px 宽,两个标签直接叠在一起。每月一次后间隔必然 ≥ 4 列。
|
||||
6. **连续完成天数允许「今天还没开始」**:今天没有记录就从昨天起算。否则每天零点一过 streak 归零,
|
||||
等于在用户还没开始工作的时候先把成绩抹掉。
|
||||
7. 回收站里的任务不计入(软删除的语义是「这件事不算了」),已归档的计入(归档只是隐藏)。
|
||||
8. 没有完成记录时仍然渲染完整网格,只把脚注换成引导文案 —— 空白的热力图本身就在表达「还没有开始」。
|
||||
9. **月份标签绝对定位**。53 列时容器 `clientWidth` 814px、`scrollWidth` 828px,凭空多出一条横向滚动条。
|
||||
根因不是格子尺寸(11px + 3px 间距本是按 814px 排的),而是月份标签的 `whitespace-nowrap` 把所在列
|
||||
从 11px 撑到 20px 以上 —— 流内文字参与了列宽计算。改为 `absolute` 定位后列宽恒定,原位置只留
|
||||
`h-[14px]` 占位块保住行高,1280 / 1024 / 900 三种宽度下 `scrollWidth === clientWidth`。
|
||||
**_2026-10-25 修正_**:当时用 `inline-flex w-fit mx-auto` 想让整块居中,但 `inline-flex` 是**行内级**
|
||||
元素,auto margin 在水平方向按 0 处理,居中根本没生效 —— 实测 814 的容器里左边贴 15px、右边空 60px,
|
||||
整块偏左,右侧一大块空白。
|
||||
改为让周列容器 `flex-1` 占满剩余宽度、**`justify-between` 把富余空间均分到 52 个间隙**:
|
||||
宽屏时间隙从 3px 涨到 4.15px,格子保持 11×11 正方。
|
||||
窄屏用 `minWidth: weeks × 14 − 3`(53 列 = 739px)兜住 3px 最小间隙,装不下就横向滚动 ——
|
||||
格子尺寸在任何宽度下都不变,只是间隙变化。
|
||||
**_同日二次修正_**:铺满后最右列顶死在容器右边界(`rightGap = 0`),把今天那格的 `ring-1` 切掉一条 ——
|
||||
`ring` 是画在元素外侧的 box-shadow,超出 `overflow-x-auto` 边界即被裁(实测超出 `innerRight` 恰好 1px)。
|
||||
给内容补 `pr-[15px]`,即左侧星期标签列的镜像(`w-3` 12px + 父级 gap 3px),
|
||||
方块阵列距内容左右边界各 15px,几何上真正对称,同时给描边留出余量(`ringOverflow` 由 +1 变 −14)。
|
||||
`pt-[1px]` 同理,为落在最上面一行的 today 描边留出上侧空间。
|
||||
_不靠 `justify-center` 居中_:内容超宽时它会从左边缘开始裁,第一列看不见。
|
||||
_不把富余宽度摊给格子_:那会让格子变成 12.1×11 的扁方块,14px 见方的阵列里 1px 的偏差是能看出来的。
|
||||
_不给 `overflow` 容器直接加 `padding-right`_:历史实现里 padding 不计入 `scrollWidth`,
|
||||
放在内层 flex 上可以绕开这个差异。
|
||||
10. **热力图之后补两块内容**:标签分布(「时间花在哪」)与四项细节指标(平均完成周期 / 最长连续 /
|
||||
有记录 / 平均每天)。理由:热力图只回答「哪几天动了手」,回答不了「时间花在什么方向」。
|
||||
两块都是既有数据的第二种切法,没有引入新存储。
|
||||
11. **标签分布按「每个标签各计一次」**,而不是把任务分桶到某个标签。一个任务同时属于「后端」和「安全」,
|
||||
对两个方向都算贡献;因此各项之和会大于完成任务数,这一点在卡片脚注与 README 里都写明,
|
||||
免得用户拿它跟「累计完成」对不上账。
|
||||
12. **完成周期取中位数**,并且不足一天显示「当天」。平均值会被一条躺了半年的任务拉到 60 天以上,
|
||||
中位数仍然描述「大多数活多久能做完」。负数跨度(时钟回拨或被手工改过的数据)直接跳过。
|
||||
13. **「平均每天」按有记录的天数算**,不按自然日。按自然日会把周末与休假算成效率下降。
|
||||
|
||||
**被否决的方案**
|
||||
|
||||
- _新增 `dailyCompletions` 计数表_:能得到不随删除变化的准确历史,代价是「两份真相」——
|
||||
恢复一条被删的任务要不要把计数加回去?撤销删除、合并导入、快照恢复都会撞上这个问题。
|
||||
宁可数字偏保守,也不要出现「热力图说完成了 5 个,任务列表只有 3 个」。
|
||||
- _把热力图放进今日视图顶部_:今日视图腾不开(一排 7 个分组 + 统计卡片已经够满),
|
||||
而且它回答的不是「今天该做什么」。做成独立页面才能给足高度和一年的宽度。
|
||||
- _画柱状趋势图_:热力图本身就是趋势的可视化,再叠一个按周聚合的柱状图是同一组数据的第二遍表达。
|
||||
确实要看「最近几周在变多还是变少」时,热力图右侧那几列的深浅已经能看出来。
|
||||
- _按最大值相对分档上色_:见结论 4。
|
||||
- _给热力图每个格子加独立的可访问名称_:一年 371 个格子逐个朗读只会变成噪音,
|
||||
改为容器一个 `role="img"` 汇总「有多少天产生过记录、累计多少个」,格子本身 `aria-hidden`,
|
||||
悬停的 `title` 作为视觉用户的补充。
|
||||
- _把统计入口做成今日视图里的一个按钮_:不可深链、无法收藏,PWA 的 shortcuts 也没有落点。
|
||||
- _完成周期用平均值_:见结论 12。平均值仍然算出来留在 `completionCycle().mean` 里供后续使用,
|
||||
只是不作为主指标展示 —— 将来若要做「异常长尾」的提示,那个字段就是入口。
|
||||
- _标签分布用饼图_:六个标签的饼图无法按长度比较,且标签名必须挤在图例里。横向条形按长度对齐,
|
||||
标签名左侧留白足够,还能顺便带上标签自己的颜色圆点。
|
||||
- _压缩热力图格子来消除滚动条_:把 11px 压到 8px 确实能塞下,但 8px 的方块已经分不清是「一格」
|
||||
还是「两个挨着的格子」。滚动条是果,列宽被文字撑开才是因,改因不改果。
|
||||
|
||||
**影响**
|
||||
|
||||
- 新增 `src/utils/activity.ts`(纯函数:分组、热力图排布、汇总、连续天数、标签分布、完成周期、
|
||||
最长连续)与 `src/views/StatsView.vue`。
|
||||
组件的职责只有取数与渲染,所有口径都在纯函数里,时间可注入,便于测跨周与跨月的边界。
|
||||
- 路由新增 `/stats`;`AppNav` 的入口从两个变三个,`AppNav.spec.ts` 的断言同步更新。
|
||||
- 热力图格子带 `data-role="heatmap-cell"` 与 `data-date` / `data-count` / `data-level`,
|
||||
标签条形带 `data-role="tag-bar"` 与 `data-tag` / `data-count`:
|
||||
单测和 e2e 都靠它定位,避免依赖 `size-[11px]` 这类会被改动的样式类。
|
||||
- 窄屏(< 768px)热力图仍然横向滚动,这是有意的:一年 53 列在手机上无法在不失真的前提下压缩。
|
||||
- 数值会随删除而减少。这一点写进了 README 的已知限制,也写在统计页脚注里 ——
|
||||
用户看到数字变少时能立刻知道原因,而不是怀疑数据坏了。
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
import { expect, test } from '@playwright/test'
|
||||
|
||||
import { addTask } from './helpers'
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/')
|
||||
})
|
||||
|
||||
/** 点卡片左侧的勾选按钮标记完成 */
|
||||
async function completeTask(page: Parameters<typeof addTask>[0], title: string): Promise<void> {
|
||||
await page.locator('article').filter({ hasText: title }).locator('button').first().click()
|
||||
}
|
||||
|
||||
/** 本地日期 YYYY-MM-DD,与热力图的口径一致 */
|
||||
async function todayIso(page: Parameters<typeof addTask>[0]): Promise<string> {
|
||||
return page.evaluate(() => {
|
||||
const now = new Date()
|
||||
const pad = (value: number) => String(value).padStart(2, '0')
|
||||
return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`
|
||||
})
|
||||
}
|
||||
|
||||
test('统计页可从导航进入,汇总数字与热力图同步更新', async ({ page }) => {
|
||||
await addTask(page, '统计任务 A')
|
||||
await completeTask(page, '统计任务 A')
|
||||
await addTask(page, '统计任务 B')
|
||||
await completeTask(page, '统计任务 B')
|
||||
|
||||
await page.getByRole('link', { name: '统计' }).click()
|
||||
await expect(page).toHaveURL(/\/stats$/)
|
||||
await expect(page.getByRole('heading', { name: '统计' })).toBeVisible()
|
||||
|
||||
// 累计与「今天完成」都是 2
|
||||
await expect(
|
||||
page.locator('dl > div').filter({ hasText: '累计完成' }).locator('dd'),
|
||||
).toContainText('2')
|
||||
await expect(
|
||||
page.locator('dl > div').filter({ hasText: '今天完成' }).locator('dd'),
|
||||
).toContainText('2')
|
||||
|
||||
// 一年 53 周 × 7 天
|
||||
await expect(page.locator('[data-role="heatmap-cell"]')).toHaveCount(53 * 7)
|
||||
|
||||
const today = await todayIso(page)
|
||||
await expect(page.locator(`[data-role="heatmap-cell"][data-date="${today}"]`)).toHaveAttribute(
|
||||
'data-count',
|
||||
'2',
|
||||
)
|
||||
})
|
||||
|
||||
test('深链 /stats 可以直接打开,空数据时给出引导', async ({ page }) => {
|
||||
await page.goto('/stats')
|
||||
|
||||
await expect(page.getByRole('heading', { name: '统计' })).toBeVisible()
|
||||
await expect(page.getByText('还没有完成记录')).toBeVisible()
|
||||
// 空数据也渲染完整网格,只是全部为最浅档
|
||||
await expect(page.locator('[data-role="heatmap-cell"]')).toHaveCount(53 * 7)
|
||||
})
|
||||
|
||||
test('回收站里的任务不计入统计', async ({ page }) => {
|
||||
await addTask(page, '要删掉的任务')
|
||||
await completeTask(page, '要删掉的任务')
|
||||
|
||||
// 删除入口在详情抽屉底部(卡片上只有完成勾选)
|
||||
await page.locator('article').filter({ hasText: '要删掉的任务' }).click()
|
||||
await page.getByRole('dialog').getByRole('button', { name: '删除' }).click()
|
||||
|
||||
await page.goto('/stats')
|
||||
await expect(
|
||||
page.locator('dl > div').filter({ hasText: '累计完成' }).locator('dd'),
|
||||
).toContainText('0')
|
||||
})
|
||||
|
||||
test('热力图左右留白对称,不产生横向滚动条也不裁切描边', async ({ page }) => {
|
||||
await page.goto('/stats')
|
||||
await page.waitForSelector('[data-role="heatmap-cell"]')
|
||||
|
||||
const metrics = await page.evaluate(() => {
|
||||
const scroller = document.querySelector('[role="img"]') as HTMLElement
|
||||
const cells = [...document.querySelectorAll('[data-role="heatmap-cell"]')]
|
||||
const box = scroller.getBoundingClientRect()
|
||||
const style = getComputedStyle(scroller)
|
||||
const innerLeft = box.left + parseFloat(style.paddingLeft)
|
||||
const innerRight = box.right - parseFloat(style.paddingRight)
|
||||
const first = cells[0]!.getBoundingClientRect()
|
||||
const last = cells[cells.length - 1]!.getBoundingClientRect()
|
||||
// 今天那格带 ring-1:描边画在元素外侧 1px,贴边就会被 overflow 容器裁掉
|
||||
const today = document.querySelector('[data-role="heatmap-cell"].ring-1')
|
||||
|
||||
return {
|
||||
clientWidth: scroller.clientWidth,
|
||||
scrollWidth: scroller.scrollWidth,
|
||||
leftGap: Math.round(first.left - innerLeft),
|
||||
rightGap: Math.round(innerRight - last.right),
|
||||
ringOverflow: today
|
||||
? Math.round(today.getBoundingClientRect().right + 1 - innerRight)
|
||||
: null,
|
||||
}
|
||||
})
|
||||
|
||||
// 宽屏(e2e 默认 1280)下内容不溢出,也就没有滚动条
|
||||
expect(metrics.scrollWidth).toBe(metrics.clientWidth)
|
||||
|
||||
// 左右留白相等:左侧是星期标签列(一 / 三 / 五)的宽度,右侧补了同样的宽度。
|
||||
// 少了它右侧会一直贴到卡片边缘,既不对称也会把 ring 描边切掉一条。
|
||||
expect(metrics.leftGap).toBeGreaterThan(0)
|
||||
expect(metrics.rightGap).toBe(metrics.leftGap)
|
||||
|
||||
// ring 必须留有余量,不能顶到容器的裁剪边界
|
||||
expect(metrics.ringOverflow).toBeLessThan(0)
|
||||
})
|
||||
|
||||
test('标签分布与细节指标随完成情况更新', async ({ page }) => {
|
||||
// 建一个带标签的任务,再从卡片上勾完成
|
||||
await addTask(page, '带标签的活')
|
||||
await page.locator('article').filter({ hasText: '带标签的活' }).click()
|
||||
const tagInput = page.getByLabel('添加标签')
|
||||
await tagInput.fill('设计')
|
||||
await tagInput.press('Enter')
|
||||
await page.getByRole('button', { name: '保存' }).click()
|
||||
await completeTask(page, '带标签的活')
|
||||
|
||||
await page.getByRole('link', { name: '统计' }).click()
|
||||
|
||||
// 标签分布:一条记录算在该标签名下
|
||||
const row = page.locator('[data-role="tag-bar"][data-tag="设计"]')
|
||||
await expect(row).toHaveAttribute('data-count', '1')
|
||||
await expect(row).toContainText('#设计')
|
||||
|
||||
// 细节卡片:只有今天这一天有记录,连续 1 天,平均每天 1.0 个
|
||||
await expect(page.locator('dl > div').filter({ hasText: '有记录' }).locator('dd')).toHaveText(
|
||||
'1 天',
|
||||
)
|
||||
await expect(page.locator('dl > div').filter({ hasText: '最长连续' }).locator('dd')).toHaveText(
|
||||
'1 天',
|
||||
)
|
||||
await expect(page.locator('dl > div').filter({ hasText: '平均每天' }).locator('dd')).toHaveText(
|
||||
'1.0 个',
|
||||
)
|
||||
// 刚建完就完成 → 周期不足 1 天,显示「当天」
|
||||
await expect(
|
||||
page.locator('dl > div').filter({ hasText: '平均完成周期' }).locator('dd'),
|
||||
).toHaveText('当天')
|
||||
})
|
||||
@@ -25,9 +25,9 @@ async function setup(initialPath = '/') {
|
||||
}
|
||||
|
||||
describe('AppNav 导航', () => {
|
||||
it('渲染看板与今日两个入口', async () => {
|
||||
it('渲染看板、今日与统计三个入口', async () => {
|
||||
const { wrapper } = await setup()
|
||||
expect(wrapper.findAll('a').map((node) => node.text())).toEqual(['看板', '今日'])
|
||||
expect(wrapper.findAll('a').map((node) => node.text())).toEqual(['看板', '今日', '统计'])
|
||||
})
|
||||
|
||||
it('当前路由的入口标记 aria-current', async () => {
|
||||
|
||||
@@ -12,6 +12,7 @@ const store = useTaskStore()
|
||||
const LINKS = [
|
||||
{ to: '/', label: '看板' },
|
||||
{ to: '/today', label: '今日' },
|
||||
{ to: '/stats', label: '统计' },
|
||||
] as const
|
||||
</script>
|
||||
|
||||
|
||||
@@ -26,6 +26,12 @@ export const routes = [
|
||||
component: () => import('@/views/TodayView.vue'),
|
||||
meta: { title: '今日' },
|
||||
},
|
||||
{
|
||||
path: '/stats',
|
||||
name: 'stats',
|
||||
component: () => import('@/views/StatsView.vue'),
|
||||
meta: { title: '统计' },
|
||||
},
|
||||
{ path: '/:pathMatch(.*)*', redirect: '/' },
|
||||
]
|
||||
|
||||
|
||||
@@ -0,0 +1,492 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { Task } from '@/types/task'
|
||||
|
||||
import {
|
||||
breakdownByTag,
|
||||
buildHeatmap,
|
||||
completionCycle,
|
||||
computeActivitySummary,
|
||||
computeStreak,
|
||||
countCompletionsByDate,
|
||||
DEFAULT_HEATMAP_WEEKS,
|
||||
levelFor,
|
||||
longestStreak,
|
||||
startOfWeekIso,
|
||||
UNTAGGED_LABEL,
|
||||
} from './activity'
|
||||
|
||||
/** 2026-10-24 是周六,本周一为 2026-10-19 */
|
||||
const NOW = new Date(2026, 9, 24, 12, 0, 0)
|
||||
|
||||
function makeTask(completedAt: string | null, overrides: Partial<Task> = {}): Task {
|
||||
return {
|
||||
id: `task-${completedAt ?? 'open'}-${overrides.title ?? ''}`,
|
||||
title: '任务',
|
||||
description: '',
|
||||
status: completedAt ? 'done' : 'todo',
|
||||
priority: 2,
|
||||
tags: [],
|
||||
dueDate: null,
|
||||
sortOrder: 1024,
|
||||
createdAt: '2026-10-01T00:00:00.000Z',
|
||||
updatedAt: '2026-10-01T00:00:00.000Z',
|
||||
completedAt,
|
||||
archivedAt: null,
|
||||
deletedAt: null,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
/** 用本地时间构造 ISO,避免测试断言受运行环境时区影响 */
|
||||
function localIso(year: number, month: number, day: number, hour = 12): string {
|
||||
return new Date(year, month - 1, day, hour, 0, 0).toISOString()
|
||||
}
|
||||
|
||||
describe('levelFor', () => {
|
||||
it('0 个为最浅档', () => {
|
||||
expect(levelFor(0)).toBe(0)
|
||||
})
|
||||
|
||||
it('1 到 3 个逐级加深', () => {
|
||||
expect(levelFor(1)).toBe(1)
|
||||
expect(levelFor(2)).toBe(2)
|
||||
expect(levelFor(3)).toBe(3)
|
||||
})
|
||||
|
||||
it('4 个及以上为最深档', () => {
|
||||
expect(levelFor(4)).toBe(4)
|
||||
expect(levelFor(12)).toBe(4)
|
||||
})
|
||||
|
||||
it('负数按 0 处理', () => {
|
||||
expect(levelFor(-1)).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('countCompletionsByDate', () => {
|
||||
it('没有已完成任务时为空', () => {
|
||||
const counts = countCompletionsByDate([makeTask(null)])
|
||||
expect(counts.size).toBe(0)
|
||||
})
|
||||
|
||||
it('同一天完成多条会累加', () => {
|
||||
const counts = countCompletionsByDate([
|
||||
makeTask(localIso(2026, 10, 20)),
|
||||
makeTask(localIso(2026, 10, 20), { title: '第二件' }),
|
||||
makeTask(localIso(2026, 10, 21)),
|
||||
])
|
||||
|
||||
expect(counts.get('2026-10-20')).toBe(2)
|
||||
expect(counts.get('2026-10-21')).toBe(1)
|
||||
})
|
||||
|
||||
it('晚上完成的任务算在当天,不会被 UTC 推到前一天', () => {
|
||||
const counts = countCompletionsByDate([makeTask(localIso(2026, 10, 24, 23))])
|
||||
expect(counts.get('2026-10-24')).toBe(1)
|
||||
})
|
||||
|
||||
it('清空完成时间后不计入', () => {
|
||||
const counts = countCompletionsByDate([makeTask(localIso(2026, 10, 20)), makeTask(null)])
|
||||
expect(counts.get('2026-10-20')).toBe(1)
|
||||
expect(counts.size).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('startOfWeekIso', () => {
|
||||
it('周六所在周的周一是 10 月 19 日', () => {
|
||||
expect(startOfWeekIso(NOW)).toBe('2026-10-19')
|
||||
})
|
||||
|
||||
it('周一当天返回自己', () => {
|
||||
expect(startOfWeekIso(new Date(2026, 9, 19, 9))).toBe('2026-10-19')
|
||||
})
|
||||
|
||||
it('周日属于上一周,不开启新的一周', () => {
|
||||
expect(startOfWeekIso(new Date(2026, 9, 25, 9))).toBe('2026-10-19')
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildHeatmap', () => {
|
||||
it('默认生成 53 周(一年),每周 7 天', () => {
|
||||
const weeks = buildHeatmap(new Map(), { now: NOW })
|
||||
expect(weeks).toHaveLength(53)
|
||||
expect(DEFAULT_HEATMAP_WEEKS).toBe(53)
|
||||
expect(weeks.every((week) => week.cells.length === 7)).toBe(true)
|
||||
})
|
||||
|
||||
it('列按时间正序,每列都是周一到周日', () => {
|
||||
const weeks = buildHeatmap(new Map(), { weeks: 3, now: NOW })
|
||||
|
||||
expect(weeks.map((week) => week.key)).toEqual(['2026-10-05', '2026-10-12', '2026-10-19'])
|
||||
expect(weeks[2]?.cells[0]?.date).toBe('2026-10-19')
|
||||
expect(weeks[2]?.cells[6]?.date).toBe('2026-10-25')
|
||||
})
|
||||
|
||||
it('今天之后的格子标记为 future', () => {
|
||||
const weeks = buildHeatmap(new Map(), { weeks: 1, now: NOW })
|
||||
const cells = weeks[0]?.cells ?? []
|
||||
|
||||
// 本周一 19 日到周日 25 日;今天是 24 日(下标 5)
|
||||
expect(cells[5]?.today).toBe(true)
|
||||
expect(cells.slice(0, 6).every((cell) => !cell.future)).toBe(true)
|
||||
expect(cells[6]?.future).toBe(true)
|
||||
})
|
||||
|
||||
it('带入完成数并换算成等级', () => {
|
||||
const counts = new Map([
|
||||
['2026-10-20', 1],
|
||||
['2026-10-21', 3],
|
||||
['2026-10-22', 7],
|
||||
])
|
||||
const cells = buildHeatmap(counts, { weeks: 1, now: NOW })[0]?.cells ?? []
|
||||
|
||||
expect(cells[1]?.count).toBe(1)
|
||||
expect(cells[1]?.level).toBe(1)
|
||||
expect(cells[2]?.level).toBe(3)
|
||||
expect(cells[3]?.level).toBe(4)
|
||||
expect(cells[0]?.count).toBe(0)
|
||||
expect(cells[0]?.level).toBe(0)
|
||||
})
|
||||
|
||||
it('每个月只在第一周打一个标签', () => {
|
||||
const weeks = buildHeatmap(new Map(), { weeks: 53, now: NOW })
|
||||
const labeled = weeks.filter((week) => week.monthLabel !== null)
|
||||
|
||||
// 一年覆盖 12 个月以上
|
||||
expect(labeled.length).toBeGreaterThanOrEqual(12)
|
||||
|
||||
// 同一个月不会出现两次
|
||||
const months = labeled.map((week) => week.monthLabel as string)
|
||||
expect(new Set(months).size).toBe(months.length)
|
||||
|
||||
// 标签都落在该月第一周(周一在 1–7 号)
|
||||
for (const week of labeled) {
|
||||
expect(Number(week.key.slice(-2))).toBeLessThanOrEqual(7)
|
||||
}
|
||||
})
|
||||
|
||||
it('相邻标签至少隔 4 列,14px 的列宽不会叠字', () => {
|
||||
const weeks = buildHeatmap(new Map(), { weeks: 53, now: NOW })
|
||||
const indexes = weeks.flatMap((week, index) => (week.monthLabel === null ? [] : [index]))
|
||||
|
||||
for (let position = 1; position < indexes.length; position += 1) {
|
||||
expect(indexes[position]! - indexes[position - 1]!).toBeGreaterThanOrEqual(4)
|
||||
}
|
||||
})
|
||||
|
||||
it('周数下限为 1', () => {
|
||||
expect(buildHeatmap(new Map(), { weeks: 0, now: NOW })).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('computeStreak', () => {
|
||||
it('今天有完成时从今天开始数', () => {
|
||||
const counts = new Map([
|
||||
['2026-10-24', 1],
|
||||
['2026-10-23', 2],
|
||||
['2026-10-22', 1],
|
||||
])
|
||||
expect(computeStreak(counts, NOW)).toBe(3)
|
||||
})
|
||||
|
||||
it('今天还没完成时从昨天开始数,不直接归零', () => {
|
||||
const counts = new Map([
|
||||
['2026-10-23', 1],
|
||||
['2026-10-22', 1],
|
||||
])
|
||||
expect(computeStreak(counts, NOW)).toBe(2)
|
||||
})
|
||||
|
||||
it('断档处停止', () => {
|
||||
const counts = new Map([
|
||||
['2026-10-24', 1],
|
||||
['2026-10-22', 1],
|
||||
])
|
||||
expect(computeStreak(counts, NOW)).toBe(1)
|
||||
})
|
||||
|
||||
it('从未完成过为 0', () => {
|
||||
expect(computeStreak(new Map(), NOW)).toBe(0)
|
||||
})
|
||||
|
||||
it('今天与昨天都没有完成时为 0', () => {
|
||||
const counts = new Map([['2026-10-22', 5]])
|
||||
expect(computeStreak(counts, NOW)).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('computeActivitySummary', () => {
|
||||
const counts = new Map([
|
||||
['2026-10-24', 2], // 今天
|
||||
['2026-10-20', 1], // 本周
|
||||
['2026-10-05', 3], // 本月,非本周
|
||||
['2026-09-28', 4], // 上个月
|
||||
])
|
||||
|
||||
it('汇总今天、本周、本月与总数', () => {
|
||||
const summary = computeActivitySummary(counts, NOW)
|
||||
|
||||
expect(summary.today).toBe(2)
|
||||
expect(summary.thisWeek).toBe(3)
|
||||
expect(summary.thisMonth).toBe(6)
|
||||
expect(summary.total).toBe(10)
|
||||
expect(summary.activeDays).toBe(4)
|
||||
})
|
||||
|
||||
it('连续天数复用 computeStreak', () => {
|
||||
expect(computeActivitySummary(counts, NOW).streak).toBe(1)
|
||||
})
|
||||
|
||||
it('最忙的一天取完成数最多的那天', () => {
|
||||
expect(computeActivitySummary(counts, NOW).bestDay).toEqual({
|
||||
date: '2026-09-28',
|
||||
count: 4,
|
||||
})
|
||||
})
|
||||
|
||||
it('并列时取较晚的一天', () => {
|
||||
const tie = new Map([
|
||||
['2026-10-20', 3],
|
||||
['2026-10-22', 3],
|
||||
])
|
||||
expect(computeActivitySummary(tie, NOW).bestDay).toEqual({ date: '2026-10-22', count: 3 })
|
||||
})
|
||||
|
||||
it('没有完成记录时各项为 0,bestDay 为 null', () => {
|
||||
const summary = computeActivitySummary(new Map(), NOW)
|
||||
|
||||
expect(summary).toEqual({
|
||||
today: 0,
|
||||
thisWeek: 0,
|
||||
thisMonth: 0,
|
||||
total: 0,
|
||||
streak: 0,
|
||||
bestDay: null,
|
||||
activeDays: 0,
|
||||
})
|
||||
})
|
||||
|
||||
it('未来日期的记录不计入本周与本月', () => {
|
||||
const future = new Map([['2026-10-30', 5]])
|
||||
const summary = computeActivitySummary(future, NOW)
|
||||
|
||||
expect(summary.total).toBe(5)
|
||||
expect(summary.thisWeek).toBe(0)
|
||||
expect(summary.thisMonth).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('breakdownByTag', () => {
|
||||
it('没有已完成任务时为空数组', () => {
|
||||
expect(breakdownByTag([makeTask(null)])).toEqual([])
|
||||
})
|
||||
|
||||
it('未完成的任务不计入', () => {
|
||||
const items = breakdownByTag([
|
||||
makeTask(localIso(2026, 10, 20), { tags: ['前端'] }),
|
||||
makeTask(null, { title: '还没做', tags: ['前端'] }),
|
||||
])
|
||||
|
||||
expect(items).toEqual([{ tag: '前端', count: 1 }])
|
||||
})
|
||||
|
||||
it('多标签任务在每个标签下各计一次', () => {
|
||||
const items = breakdownByTag([
|
||||
makeTask(localIso(2026, 10, 20), { tags: ['后端', '安全'] }),
|
||||
makeTask(localIso(2026, 10, 21), { title: 'b', tags: ['安全'] }),
|
||||
])
|
||||
|
||||
expect(items).toEqual([
|
||||
{ tag: '安全', count: 2 },
|
||||
{ tag: '后端', count: 1 },
|
||||
])
|
||||
// 各项之和大于任务数,这是刻意的:多标签任务对多个方向都算贡献
|
||||
expect(items.reduce((sum, item) => sum + item.count, 0)).toBe(3)
|
||||
})
|
||||
|
||||
it('没有标签的已完成任务归入「未分类」', () => {
|
||||
const items = breakdownByTag([
|
||||
makeTask(localIso(2026, 10, 20)),
|
||||
makeTask(localIso(2026, 10, 21), { title: 'b' }),
|
||||
makeTask(localIso(2026, 10, 22), { title: 'c', tags: ['前端'] }),
|
||||
])
|
||||
|
||||
expect(items).toContainEqual({ tag: UNTAGGED_LABEL, count: 2 })
|
||||
expect(UNTAGGED_LABEL).toBe('未分类')
|
||||
})
|
||||
|
||||
it('全部未分类时不额外产生空标签项', () => {
|
||||
const items = breakdownByTag([makeTask(localIso(2026, 10, 20))])
|
||||
expect(items).toEqual([{ tag: UNTAGGED_LABEL, count: 1 }])
|
||||
})
|
||||
|
||||
it('按数量降序排列', () => {
|
||||
const items = breakdownByTag([
|
||||
makeTask(localIso(2026, 10, 20), { tags: ['少'] }),
|
||||
makeTask(localIso(2026, 10, 21), { title: 'b', tags: ['多'] }),
|
||||
makeTask(localIso(2026, 10, 22), { title: 'c', tags: ['多'] }),
|
||||
makeTask(localIso(2026, 10, 23), { title: 'd', tags: ['多'] }),
|
||||
])
|
||||
|
||||
expect(items.map((item) => item.tag)).toEqual(['多', '少'])
|
||||
})
|
||||
|
||||
it('数量相同时按标签名排序,保证渲染顺序稳定', () => {
|
||||
const first = breakdownByTag([
|
||||
makeTask(localIso(2026, 10, 20), { tags: ['b'] }),
|
||||
makeTask(localIso(2026, 10, 21), { title: 'x', tags: ['a'] }),
|
||||
])
|
||||
const second = breakdownByTag([
|
||||
makeTask(localIso(2026, 10, 20), { tags: ['a'] }),
|
||||
makeTask(localIso(2026, 10, 21), { title: 'x', tags: ['b'] }),
|
||||
])
|
||||
|
||||
expect(first.map((item) => item.tag)).toEqual(['a', 'b'])
|
||||
expect(second.map((item) => item.tag)).toEqual(['a', 'b'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('completionCycle', () => {
|
||||
it('没有已完成任务时返回 null', () => {
|
||||
expect(completionCycle([makeTask(null)])).toBeNull()
|
||||
})
|
||||
|
||||
it('单个样本时中位数、平均都等于该跨度', () => {
|
||||
const cycle = completionCycle([
|
||||
makeTask(localIso(2026, 10, 3, 0), { createdAt: localIso(2026, 10, 1, 0) }),
|
||||
])
|
||||
|
||||
expect(cycle).toEqual({ median: 2, mean: 2, sample: 1 })
|
||||
})
|
||||
|
||||
it('奇数个样本取中间值', () => {
|
||||
const cycle = completionCycle([
|
||||
makeTask(localIso(2026, 10, 3, 0), { title: 'a', createdAt: localIso(2026, 10, 1, 0) }),
|
||||
makeTask(localIso(2026, 10, 13, 0), { title: 'b', createdAt: localIso(2026, 10, 1, 0) }),
|
||||
makeTask(localIso(2026, 10, 8, 0), { title: 'c', createdAt: localIso(2026, 10, 1, 0) }),
|
||||
])
|
||||
|
||||
// 跨度为 2、12、7 → 排序后 2、7、12,中位数 7
|
||||
expect(cycle?.median).toBe(7)
|
||||
expect(cycle?.sample).toBe(3)
|
||||
expect(cycle?.mean).toBeCloseTo(7, 5)
|
||||
})
|
||||
|
||||
it('偶数个样本取中间两个的平均', () => {
|
||||
const cycle = completionCycle([
|
||||
makeTask(localIso(2026, 10, 3, 0), { title: 'a', createdAt: localIso(2026, 10, 1, 0) }),
|
||||
makeTask(localIso(2026, 10, 11, 0), { title: 'b', createdAt: localIso(2026, 10, 1, 0) }),
|
||||
])
|
||||
|
||||
// 跨度为 2、10 → 中位数 6
|
||||
expect(cycle?.median).toBe(6)
|
||||
expect(cycle?.sample).toBe(2)
|
||||
})
|
||||
|
||||
it('中位数不被极端值拉偏', () => {
|
||||
const cycle = completionCycle([
|
||||
makeTask(localIso(2026, 10, 2, 0), { title: 'a', createdAt: localIso(2026, 10, 1, 0) }),
|
||||
makeTask(localIso(2026, 10, 3, 0), { title: 'b', createdAt: localIso(2026, 10, 1, 0) }),
|
||||
makeTask(localIso(2027, 4, 1, 0), { title: '躺了半年', createdAt: localIso(2026, 10, 1, 0) }),
|
||||
])
|
||||
|
||||
expect(cycle?.median).toBe(2)
|
||||
// 平均被那条躺了半年的任务拉到 60 天以上 —— 这正是主指标用中位数的原因
|
||||
expect(cycle?.mean).toBeGreaterThan(50)
|
||||
})
|
||||
|
||||
it('完成时间早于创建时间的脏数据直接跳过', () => {
|
||||
const cycle = completionCycle([
|
||||
makeTask(localIso(2026, 10, 1, 0), { title: '回溯', createdAt: localIso(2026, 10, 5, 0) }),
|
||||
makeTask(localIso(2026, 10, 3, 0), { title: '正常', createdAt: localIso(2026, 10, 1, 0) }),
|
||||
])
|
||||
|
||||
expect(cycle?.sample).toBe(1)
|
||||
expect(cycle?.median).toBe(2)
|
||||
})
|
||||
|
||||
it('当天创建当天完成时跨度不足 1 天', () => {
|
||||
const cycle = completionCycle([
|
||||
makeTask(localIso(2026, 10, 1, 20), { createdAt: localIso(2026, 10, 1, 9) }),
|
||||
])
|
||||
|
||||
// 9:00 → 20:00 共 11 小时 = 0.46 天;视图据此显示「当天」而不是「0.5 天」
|
||||
expect(cycle?.median).toBeCloseTo(11 / 24, 5)
|
||||
expect(cycle?.median).toBeLessThan(1)
|
||||
})
|
||||
|
||||
it('未完成的任务不参与统计', () => {
|
||||
const cycle = completionCycle([
|
||||
makeTask(localIso(2026, 10, 3, 0), { createdAt: localIso(2026, 10, 1, 0) }),
|
||||
makeTask(null, { title: '还在做', createdAt: localIso(2026, 10, 1, 0) }),
|
||||
])
|
||||
|
||||
expect(cycle?.sample).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('longestStreak', () => {
|
||||
it('没有任何记录时为 0', () => {
|
||||
expect(longestStreak(new Map())).toBe(0)
|
||||
})
|
||||
|
||||
it('连续的几天取最长的一段', () => {
|
||||
const counts = new Map([
|
||||
['2026-10-01', 1],
|
||||
['2026-10-02', 3],
|
||||
['2026-10-03', 1],
|
||||
['2026-10-10', 1],
|
||||
['2026-10-11', 2],
|
||||
])
|
||||
|
||||
expect(longestStreak(counts)).toBe(3)
|
||||
})
|
||||
|
||||
it('跨月连续合并计算', () => {
|
||||
const counts = new Map([
|
||||
['2026-09-29', 1],
|
||||
['2026-09-30', 1],
|
||||
['2026-10-01', 1],
|
||||
['2026-10-02', 1],
|
||||
])
|
||||
|
||||
expect(longestStreak(counts)).toBe(4)
|
||||
})
|
||||
|
||||
it('计数为 0 的日期不算连续', () => {
|
||||
const counts = new Map([
|
||||
['2026-10-01', 1],
|
||||
['2026-10-02', 0],
|
||||
['2026-10-03', 1],
|
||||
])
|
||||
|
||||
expect(longestStreak(counts)).toBe(1)
|
||||
})
|
||||
|
||||
it('全部不连续时为 1', () => {
|
||||
const counts = new Map([
|
||||
['2026-10-01', 1],
|
||||
['2026-10-05', 1],
|
||||
['2026-10-20', 1],
|
||||
])
|
||||
|
||||
expect(longestStreak(counts)).toBe(1)
|
||||
})
|
||||
|
||||
it('不受 Map 插入顺序影响', () => {
|
||||
const forward = new Map([
|
||||
['2026-10-01', 1],
|
||||
['2026-10-02', 1],
|
||||
['2026-10-03', 1],
|
||||
])
|
||||
const backward = new Map([
|
||||
['2026-10-03', 1],
|
||||
['2026-10-02', 1],
|
||||
['2026-10-01', 1],
|
||||
])
|
||||
|
||||
expect(longestStreak(backward)).toBe(longestStreak(forward))
|
||||
expect(longestStreak(backward)).toBe(3)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,326 @@
|
||||
/**
|
||||
* 完成活动统计:按日汇总、热力图、汇总指标(见 ADR-012)
|
||||
*
|
||||
* 数据来源**只有** `Task.completedAt`,不额外存「每日完成计数」。
|
||||
* 因此这里的统计是「基于现存任务的近似」:永久删除、清空回收站、
|
||||
* 超过 30 天自动清理的任务,会连同它那天的完成记录一起消失。
|
||||
*
|
||||
* 这是刻意取舍:引入第二份计数数据能得到不随删除变化的准确历史,
|
||||
* 代价是「两份真相」—— 恢复一条被删的任务时该不该把计数加回去?
|
||||
* 撤销删除、合并导入都会遇到这个问题。宁可数字偏保守,也不要自相矛盾。
|
||||
*/
|
||||
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
import type { Task } from '@/types/task'
|
||||
import { localDateOf } from '@/utils/date'
|
||||
|
||||
/** 热力图默认展示的周数(一年)。半年只占卡片一半宽,且看不出季节性的起伏 */
|
||||
export const DEFAULT_HEATMAP_WEEKS = 53
|
||||
|
||||
export type HeatmapLevel = 0 | 1 | 2 | 3 | 4
|
||||
|
||||
/**
|
||||
* 强度分档:当天完成数 → 等级。
|
||||
* 固定阈值比按最大值分档更可预测 —— 后者在数据变多时会让所有格子一起变深,
|
||||
* 反而看不出「哪天特别猛」。
|
||||
*/
|
||||
export function levelFor(count: number): HeatmapLevel {
|
||||
if (count <= 0) return 0
|
||||
if (count === 1) return 1
|
||||
if (count === 2) return 2
|
||||
if (count === 3) return 3
|
||||
return 4
|
||||
}
|
||||
|
||||
export interface HeatmapCell {
|
||||
/** YYYY-MM-DD(本地日期) */
|
||||
date: string
|
||||
count: number
|
||||
level: HeatmapLevel
|
||||
/** 今天之后的日期:留白,避免出现「未来已经完成了 0 个」这种噪音 */
|
||||
future: boolean
|
||||
/** 是否为今天 */
|
||||
today: boolean
|
||||
}
|
||||
|
||||
export interface HeatmapWeek {
|
||||
/** 该周周一的日期,作为渲染 key */
|
||||
key: string
|
||||
/** 周一到周日,共 7 格 */
|
||||
cells: HeatmapCell[]
|
||||
/** 需要显示月份标签时给出(如「9月」),否则为 null */
|
||||
monthLabel: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* 按本地日期汇总完成数。
|
||||
*
|
||||
* 必须逐条走 `localDateOf`:`completedAt` 存的是 UTC ISO,
|
||||
* 直接截取前 10 位在东八区会把晚上完成的任务算到前一天。
|
||||
*/
|
||||
export function countCompletionsByDate(tasks: readonly Task[]): Map<string, number> {
|
||||
const counts = new Map<string, number>()
|
||||
|
||||
for (const task of tasks) {
|
||||
if (task.completedAt === null) continue
|
||||
const date = localDateOf(task.completedAt)
|
||||
if (date === null) continue
|
||||
counts.set(date, (counts.get(date) ?? 0) + 1)
|
||||
}
|
||||
|
||||
return counts
|
||||
}
|
||||
|
||||
/** 本周周一的日期(周一为一周之始,与 `utils/stats.ts` 的口径一致) */
|
||||
export function startOfWeekIso(now: Date = new Date()): string {
|
||||
const current = dayjs(now).startOf('day')
|
||||
return current.subtract((current.day() + 6) % 7, 'day').format('YYYY-MM-DD')
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成热力图:列为一周,行为周一到周日,按时间正序。
|
||||
*
|
||||
* 传入 `counts` 而不是任务列表,是为了让这个函数只负责「排布」,
|
||||
* 汇总与排布可以分开测试。
|
||||
*/
|
||||
export function buildHeatmap(
|
||||
counts: ReadonlyMap<string, number>,
|
||||
options: { weeks?: number; now?: Date } = {},
|
||||
): HeatmapWeek[] {
|
||||
const weeks = Math.max(1, options.weeks ?? DEFAULT_HEATMAP_WEEKS)
|
||||
const today = dayjs(options.now ?? new Date()).startOf('day')
|
||||
|
||||
const currentWeekStart = today.subtract((today.day() + 6) % 7, 'day')
|
||||
const firstWeekStart = currentWeekStart.subtract(weeks - 1, 'week')
|
||||
|
||||
const result: HeatmapWeek[] = []
|
||||
|
||||
for (let index = 0; index < weeks; index += 1) {
|
||||
const weekStart = firstWeekStart.add(index, 'week')
|
||||
const cells: HeatmapCell[] = []
|
||||
|
||||
for (let offset = 0; offset < 7; offset += 1) {
|
||||
const day = weekStart.add(offset, 'day')
|
||||
const date = day.format('YYYY-MM-DD')
|
||||
const count = counts.get(date) ?? 0
|
||||
|
||||
cells.push({
|
||||
date,
|
||||
count,
|
||||
level: levelFor(count),
|
||||
future: day.isAfter(today, 'day'),
|
||||
today: day.isSame(today, 'day'),
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 只在该月第一周打标签(周一落在 1–7 号)。
|
||||
*
|
||||
* 用「月份变了就打」会让 4 月与 5 月这种相邻的两周各打一个,
|
||||
* 而每列只有 14px 宽,两个标签会叠在一起。每个月恰好一次,
|
||||
* 间隔必然在 4 列以上,也就不会撞车。
|
||||
*/
|
||||
const labelMonth = weekStart.date() <= 7 ? weekStart.month() + 1 : null
|
||||
|
||||
result.push({
|
||||
key: weekStart.format('YYYY-MM-DD'),
|
||||
cells,
|
||||
monthLabel: labelMonth === null ? null : `${labelMonth}月`,
|
||||
})
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
export interface ActivitySummary {
|
||||
/** 今天完成数 */
|
||||
today: number
|
||||
/** 本周(周一起)完成数 */
|
||||
thisWeek: number
|
||||
/** 本月完成数 */
|
||||
thisMonth: number
|
||||
/** 全部完成数 */
|
||||
total: number
|
||||
/** 连续完成天数 */
|
||||
streak: number
|
||||
/** 完成最多的一天;从未完成过时为 null */
|
||||
bestDay: { date: string; count: number } | null
|
||||
/** 有完成记录的天数 */
|
||||
activeDays: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 连续完成天数。
|
||||
*
|
||||
* 今天还没完成时从昨天开始数 —— 否则每天零点一过 streak 就归零,
|
||||
* 会在用户还没开始工作的时候先把成绩抹掉。
|
||||
*/
|
||||
export function computeStreak(counts: ReadonlyMap<string, number>, now: Date = new Date()): number {
|
||||
let cursor = dayjs(now).startOf('day')
|
||||
if ((counts.get(cursor.format('YYYY-MM-DD')) ?? 0) === 0) {
|
||||
cursor = cursor.subtract(1, 'day')
|
||||
}
|
||||
|
||||
let streak = 0
|
||||
while ((counts.get(cursor.format('YYYY-MM-DD')) ?? 0) > 0) {
|
||||
streak += 1
|
||||
cursor = cursor.subtract(1, 'day')
|
||||
}
|
||||
|
||||
return streak
|
||||
}
|
||||
|
||||
export function computeActivitySummary(
|
||||
counts: ReadonlyMap<string, number>,
|
||||
now: Date = new Date(),
|
||||
): ActivitySummary {
|
||||
const todayIso = dayjs(now).format('YYYY-MM-DD')
|
||||
const weekStart = startOfWeekIso(now)
|
||||
const monthStart = dayjs(now).startOf('month').format('YYYY-MM-DD')
|
||||
|
||||
let total = 0
|
||||
let thisWeek = 0
|
||||
let thisMonth = 0
|
||||
let activeDays = 0
|
||||
let bestDay: { date: string; count: number } | null = null
|
||||
|
||||
for (const [date, count] of counts) {
|
||||
if (count <= 0) continue
|
||||
|
||||
total += count
|
||||
activeDays += 1
|
||||
if (date >= weekStart && date <= todayIso) thisWeek += count
|
||||
if (date >= monthStart && date <= todayIso) thisMonth += count
|
||||
|
||||
// 并列时取较晚的一天:最近的高光比上个月的更有参考价值
|
||||
if (
|
||||
bestDay === null ||
|
||||
count > bestDay.count ||
|
||||
(count === bestDay.count && date > bestDay.date)
|
||||
) {
|
||||
bestDay = { date, count }
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
today: counts.get(todayIso) ?? 0,
|
||||
thisWeek,
|
||||
thisMonth,
|
||||
total,
|
||||
streak: computeStreak(counts, now),
|
||||
bestDay,
|
||||
activeDays,
|
||||
}
|
||||
}
|
||||
|
||||
/** 没有标签的已完成任务在分布里归到这个名字 */
|
||||
export const UNTAGGED_LABEL = '未分类'
|
||||
|
||||
export interface TagBreakdownItem {
|
||||
tag: string
|
||||
count: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 已完任务按标签分布,按数量降序。
|
||||
*
|
||||
* 一个任务有多个标签时每个标签各 +1,因此各项之和会大于任务数。
|
||||
* 这里回答的是「这类活做了多少件」,不是「任务如何分桶」——
|
||||
* 一件同时属于「后端」和「安全」的活,对两个方向都算贡献。
|
||||
*/
|
||||
export function breakdownByTag(tasks: readonly Task[]): TagBreakdownItem[] {
|
||||
const counter = new Map<string, number>()
|
||||
let untagged = 0
|
||||
|
||||
for (const task of tasks) {
|
||||
if (task.completedAt === null) continue
|
||||
|
||||
if (task.tags.length === 0) {
|
||||
untagged += 1
|
||||
continue
|
||||
}
|
||||
for (const tag of task.tags) {
|
||||
counter.set(tag, (counter.get(tag) ?? 0) + 1)
|
||||
}
|
||||
}
|
||||
|
||||
const items: TagBreakdownItem[] = [...counter.entries()].map(([tag, count]) => ({ tag, count }))
|
||||
if (untagged > 0) items.push({ tag: UNTAGGED_LABEL, count: untagged })
|
||||
|
||||
// 数量相同时按标签名排,保证每次渲染顺序稳定
|
||||
return items.sort(
|
||||
(a, b) => b.count - a.count || a.tag.localeCompare(b.tag, 'zh-Hans-CN'),
|
||||
)
|
||||
}
|
||||
|
||||
export interface CompletionCycle {
|
||||
/** 中位数天数 —— 抗极端值,代表「通常要多久」 */
|
||||
median: number
|
||||
/** 平均天数 */
|
||||
mean: number
|
||||
/** 参与统计的任务数 */
|
||||
sample: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 从创建到完成要多久,只统计已完成的任务。
|
||||
*
|
||||
* 用中位数而不是平均作为主指标:一条躺了半年的任务就能把平均值拉到离谱,
|
||||
* 而中位数仍然描述「大多数活多久能做完」。没有可统计的任务时返回 null。
|
||||
*/
|
||||
export function completionCycle(tasks: readonly Task[]): CompletionCycle | null {
|
||||
const days: number[] = []
|
||||
|
||||
for (const task of tasks) {
|
||||
if (task.completedAt === null) continue
|
||||
|
||||
const created = dayjs(task.createdAt)
|
||||
const completed = dayjs(task.completedAt)
|
||||
if (!created.isValid() || !completed.isValid()) continue
|
||||
|
||||
const span = completed.diff(created, 'day', true)
|
||||
// 时钟回拨或被手工改过的数据会出现负数,直接跳过而不是让它污染中位数
|
||||
if (span < 0) continue
|
||||
days.push(span)
|
||||
}
|
||||
|
||||
if (days.length === 0) return null
|
||||
|
||||
days.sort((a, b) => a - b)
|
||||
const middle = Math.floor(days.length / 2)
|
||||
const median =
|
||||
days.length % 2 === 0
|
||||
? ((days[middle - 1] ?? 0) + (days[middle] ?? 0)) / 2
|
||||
: (days[middle] ?? 0)
|
||||
const mean = days.reduce((sum, value) => sum + value, 0) / days.length
|
||||
|
||||
return { median, mean, sample: days.length }
|
||||
}
|
||||
|
||||
/**
|
||||
* 历史最长连续完成天数。
|
||||
*
|
||||
* 与 `computeStreak`(当前连续)不同:这个看整个记录期,
|
||||
* 用来回答「我最拼的那段时间坚持了多久」。
|
||||
*/
|
||||
export function longestStreak(counts: ReadonlyMap<string, number>): number {
|
||||
const dates = [...counts.entries()]
|
||||
.filter(([, count]) => count > 0)
|
||||
.map(([date]) => date)
|
||||
.sort()
|
||||
|
||||
let best = 0
|
||||
let run = 0
|
||||
let previous: string | null = null
|
||||
|
||||
for (const date of dates) {
|
||||
// 用 dayjs 算相邻差,避免自己处理月份与闰年
|
||||
const adjacent = previous !== null && dayjs(date).diff(dayjs(previous), 'day') === 1
|
||||
run = adjacent ? run + 1 : 1
|
||||
if (run > best) best = run
|
||||
previous = date
|
||||
}
|
||||
|
||||
return best
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import type { VueWrapper } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { createMemoryTaskRepository } from '@/data/taskRepository'
|
||||
import { useTaskStore } from '@/stores/tasks'
|
||||
import { DEFAULT_HEATMAP_WEEKS } from '@/utils/activity'
|
||||
import type { Task } from '@/types/task'
|
||||
|
||||
import StatsView from './StatsView.vue'
|
||||
|
||||
/** 2026-10-24 是周六 */
|
||||
const NOW = new Date(2026, 9, 24, 12, 0, 0)
|
||||
|
||||
function localIso(year: number, month: number, day: number, hour = 12): string {
|
||||
return new Date(year, month - 1, day, hour, 0, 0).toISOString()
|
||||
}
|
||||
|
||||
function makeTask(completedAt: string | null, overrides: Partial<Task> = {}): Task {
|
||||
return {
|
||||
id: `task-${completedAt ?? 'open'}-${overrides.title ?? ''}`,
|
||||
title: '任务',
|
||||
description: '',
|
||||
status: completedAt ? 'done' : 'todo',
|
||||
priority: 2,
|
||||
tags: [],
|
||||
dueDate: null,
|
||||
sortOrder: 1024,
|
||||
createdAt: '2026-10-01T00:00:00.000Z',
|
||||
updatedAt: '2026-10-01T00:00:00.000Z',
|
||||
completedAt,
|
||||
archivedAt: null,
|
||||
deletedAt: null,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function mountView(tasks: Task[] = []): VueWrapper {
|
||||
const pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
const store = useTaskStore()
|
||||
store.init({ repository: createMemoryTaskRepository(tasks), crossTab: false })
|
||||
|
||||
return mount(StatsView, { global: { plugins: [pinia] } })
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取某张统计卡片的数字。
|
||||
* 不能用「包含 dt 的第一个 div」——外层 grid 也满足,会永远读到第一张卡。
|
||||
*/
|
||||
function cardValue(wrapper: VueWrapper, label: string): string | undefined {
|
||||
const dt = wrapper.findAll('dt').find((node) => node.text() === label)
|
||||
const dd = dt?.element.parentElement?.querySelector('dd')
|
||||
return dd?.textContent?.trim()
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(NOW)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
describe('统计视图 · 空状态', () => {
|
||||
it('没有任何完成记录时各项为 0', () => {
|
||||
const wrapper = mountView([makeTask(null)])
|
||||
|
||||
expect(cardValue(wrapper, '今天完成')).toContain('0')
|
||||
expect(cardValue(wrapper, '累计完成')).toContain('0')
|
||||
})
|
||||
|
||||
it('提示如何让热力图亮起来', () => {
|
||||
const wrapper = mountView()
|
||||
expect(wrapper.text()).toContain('还没有完成记录')
|
||||
})
|
||||
|
||||
it('仍然渲染完整的热力图网格', () => {
|
||||
const wrapper = mountView()
|
||||
expect(wrapper.findAll('[data-role="heatmap-cell"]')).toHaveLength(DEFAULT_HEATMAP_WEEKS * 7)
|
||||
})
|
||||
})
|
||||
|
||||
describe('统计视图 · 汇总数字', () => {
|
||||
const tasks = [
|
||||
makeTask(localIso(2026, 10, 24)), // 今天
|
||||
makeTask(localIso(2026, 10, 24), { title: '今天第二件' }),
|
||||
makeTask(localIso(2026, 10, 20)), // 本周
|
||||
makeTask(localIso(2026, 10, 5)), // 本月
|
||||
makeTask(localIso(2026, 9, 28)), // 上月
|
||||
]
|
||||
|
||||
it('今天完成数', () => {
|
||||
expect(cardValue(mountView(tasks), '今天完成')).toContain('2')
|
||||
})
|
||||
|
||||
it('本月完成数包含本周但不含上月', () => {
|
||||
expect(cardValue(mountView(tasks), '本月完成')).toContain('4')
|
||||
})
|
||||
|
||||
it('累计完成数包含所有月份', () => {
|
||||
expect(cardValue(mountView(tasks), '累计完成')).toContain('5')
|
||||
})
|
||||
|
||||
it('连续天数按今天与昨天计算', () => {
|
||||
// 今天有记录,昨天没有 → 连续 1 天
|
||||
expect(cardValue(mountView(tasks), '连续完成')).toContain('1')
|
||||
})
|
||||
|
||||
it('显示最忙的一天', () => {
|
||||
const wrapper = mountView(tasks)
|
||||
expect(wrapper.text()).toContain('最忙的一天')
|
||||
// 今天完成了 2 个,是这批数据里最多的一天
|
||||
expect(wrapper.text()).toContain('10月24日 · 2 个')
|
||||
})
|
||||
})
|
||||
|
||||
describe('统计视图 · 热力图', () => {
|
||||
it('每个格子带上日期与完成数,供悬停查看', () => {
|
||||
const wrapper = mountView([
|
||||
makeTask(localIso(2026, 10, 22)),
|
||||
makeTask(localIso(2026, 10, 22), { title: 'b' }),
|
||||
])
|
||||
const cell = wrapper.find('[data-role="heatmap-cell"][data-date="2026-10-22"]')
|
||||
|
||||
expect(cell.exists()).toBe(true)
|
||||
expect(cell.attributes('data-count')).toBe('2')
|
||||
expect(cell.attributes('data-level')).toBe('2')
|
||||
expect(cell.attributes('title')).toContain('完成 2 个')
|
||||
})
|
||||
|
||||
it('今天之后的格子不可见', () => {
|
||||
const wrapper = mountView()
|
||||
const future = wrapper.find('[data-role="heatmap-cell"][data-date="2026-10-25"]')
|
||||
|
||||
expect(future.classes()).toContain('invisible')
|
||||
})
|
||||
|
||||
it('今天有标记但与未来格子区分开', () => {
|
||||
const wrapper = mountView()
|
||||
const today = wrapper.find('[data-role="heatmap-cell"][data-date="2026-10-24"]')
|
||||
|
||||
expect(today.classes()).not.toContain('invisible')
|
||||
expect(today.classes()).toContain('ring-1')
|
||||
})
|
||||
|
||||
it('没有完成的那天用最浅档', () => {
|
||||
const wrapper = mountView()
|
||||
const cell = wrapper.find('[data-role="heatmap-cell"][data-date="2026-10-01"]')
|
||||
expect(cell.attributes('data-level')).toBe('0')
|
||||
})
|
||||
})
|
||||
|
||||
describe('统计视图 · 回收站', () => {
|
||||
it('不统计回收站里的任务', () => {
|
||||
const deleted = makeTask(localIso(2026, 10, 24), {
|
||||
title: '删掉的',
|
||||
deletedAt: localIso(2026, 10, 24),
|
||||
})
|
||||
const wrapper = mountView([deleted])
|
||||
|
||||
expect(cardValue(wrapper, '累计完成')).toContain('0')
|
||||
})
|
||||
|
||||
it('已归档的任务仍然计入', () => {
|
||||
const archived = makeTask(localIso(2026, 10, 20), {
|
||||
title: '归档的',
|
||||
archivedAt: localIso(2026, 10, 21),
|
||||
})
|
||||
const wrapper = mountView([archived])
|
||||
|
||||
expect(cardValue(wrapper, '累计完成')).toContain('1')
|
||||
})
|
||||
})
|
||||
|
||||
describe('统计视图 · 时间花在哪', () => {
|
||||
/** 读取标签条形图的行,返回 [标签, 数量] 对 */
|
||||
function tagRows(wrapper: VueWrapper): Array<[string, number]> {
|
||||
return wrapper
|
||||
.findAll('[data-role="tag-bar"]')
|
||||
.map((node) => [
|
||||
node.attributes('data-tag') as string,
|
||||
Number(node.attributes('data-count')),
|
||||
])
|
||||
}
|
||||
|
||||
it('没有完成记录时显示空状态', () => {
|
||||
const wrapper = mountView()
|
||||
expect(wrapper.text()).toContain('还没有带标签的完成记录')
|
||||
})
|
||||
|
||||
it('按标签汇总,数量多的排在前面', () => {
|
||||
const wrapper = mountView([
|
||||
makeTask(localIso(2026, 10, 20), { tags: ['前端'] }),
|
||||
makeTask(localIso(2026, 10, 21), { title: 'b', tags: ['后端'] }),
|
||||
makeTask(localIso(2026, 10, 22), { title: 'c', tags: ['后端'] }),
|
||||
])
|
||||
|
||||
expect(tagRows(wrapper)).toEqual([
|
||||
['后端', 2],
|
||||
['前端', 1],
|
||||
])
|
||||
})
|
||||
|
||||
it('一个任务带多个标签时,每个标签各计一次', () => {
|
||||
const wrapper = mountView([makeTask(localIso(2026, 10, 20), { tags: ['后端', '安全'] })])
|
||||
|
||||
expect(tagRows(wrapper)).toEqual([
|
||||
['安全', 1],
|
||||
['后端', 1],
|
||||
])
|
||||
})
|
||||
|
||||
it('没有标签的完成记录归入「未分类」', () => {
|
||||
const wrapper = mountView([makeTask(localIso(2026, 10, 20))])
|
||||
expect(tagRows(wrapper)).toEqual([['未分类', 1]])
|
||||
})
|
||||
|
||||
it('最多只展示前 6 个标签', () => {
|
||||
const tasks = Array.from({ length: 8 }, (_, index) =>
|
||||
makeTask(localIso(2026, 10, 10 + index), { title: `t${index}`, tags: [`标签${index}`] }),
|
||||
)
|
||||
const wrapper = mountView(tasks)
|
||||
|
||||
expect(tagRows(wrapper)).toHaveLength(6)
|
||||
expect(wrapper.text()).toContain('前 6 项')
|
||||
})
|
||||
|
||||
it('标签条宽度按最大项归一', () => {
|
||||
const wrapper = mountView([
|
||||
makeTask(localIso(2026, 10, 20), { tags: ['多'] }),
|
||||
makeTask(localIso(2026, 10, 21), { title: 'b', tags: ['多'] }),
|
||||
makeTask(localIso(2026, 10, 22), { title: 'c', tags: ['少'] }),
|
||||
])
|
||||
|
||||
const bars = wrapper.findAll('[data-role="tag-bar"]')
|
||||
const width = (index: number): string =>
|
||||
bars[index]!.find('[style]').attributes('style') ?? ''
|
||||
|
||||
// 最大项占满、次项占一半
|
||||
expect(width(0)).toContain('width: 100%')
|
||||
expect(width(1)).toContain('width: 50%')
|
||||
})
|
||||
|
||||
it('未完成的任务不进入分布', () => {
|
||||
const wrapper = mountView([makeTask(null, { tags: ['还没做'] })])
|
||||
expect(tagRows(wrapper)).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('统计视图 · 一些细节', () => {
|
||||
it('没有完成记录时周期显示为占位符', () => {
|
||||
const wrapper = mountView()
|
||||
expect(cardValue(wrapper, '平均完成周期')).toBe('—')
|
||||
expect(cardValue(wrapper, '最长连续')).toBe('0 天')
|
||||
})
|
||||
|
||||
it('平均完成周期取中位数', () => {
|
||||
const wrapper = mountView([
|
||||
makeTask(localIso(2026, 10, 22, 0), { title: 'a', createdAt: localIso(2026, 10, 20, 0) }),
|
||||
makeTask(localIso(2026, 10, 23, 0), { title: 'b', createdAt: localIso(2026, 10, 21, 0) }),
|
||||
makeTask(localIso(2027, 4, 1, 0), { title: '躺了很久', createdAt: localIso(2026, 10, 1, 0) }),
|
||||
])
|
||||
|
||||
// 跨度为 2、2、182 天,中位数 2;平均会被拉偏(60 天以上),所以不用平均
|
||||
expect(cardValue(wrapper, '平均完成周期')).toBe('2.0 天')
|
||||
})
|
||||
|
||||
it('当天完成显示「当天」而不是 0 天', () => {
|
||||
const wrapper = mountView([
|
||||
makeTask(localIso(2026, 10, 24, 18), { createdAt: localIso(2026, 10, 24, 9) }),
|
||||
])
|
||||
|
||||
expect(cardValue(wrapper, '平均完成周期')).toBe('当天')
|
||||
})
|
||||
|
||||
it('最长连续与有记录天数', () => {
|
||||
const wrapper = mountView([
|
||||
makeTask(localIso(2026, 10, 22)),
|
||||
makeTask(localIso(2026, 10, 23), { title: 'b' }),
|
||||
makeTask(localIso(2026, 10, 24), { title: 'c' }),
|
||||
makeTask(localIso(2026, 10, 10), { title: 'd' }),
|
||||
])
|
||||
|
||||
expect(cardValue(wrapper, '最长连续')).toBe('3 天')
|
||||
expect(cardValue(wrapper, '有记录')).toBe('4 天')
|
||||
})
|
||||
|
||||
it('平均每天按「有记录的天数」而不是自然日计算', () => {
|
||||
const wrapper = mountView([
|
||||
makeTask(localIso(2026, 10, 22)),
|
||||
makeTask(localIso(2026, 10, 22), { title: 'b' }),
|
||||
makeTask(localIso(2026, 10, 24), { title: 'c' }),
|
||||
])
|
||||
|
||||
// 3 个任务分布在 2 天 → 1.5;不是 3 / 30 天
|
||||
expect(cardValue(wrapper, '平均每天')).toBe('1.5 个')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,325 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 统计视图:完成趋势与热力图(ADR-012)
|
||||
*
|
||||
* 与今日视图的分工:今日视图往前看(该做什么),这里往回看(做过什么)。
|
||||
* 因此做成独立路由而不是塞进今日视图 —— 两者的信息密度和使用时机都不同。
|
||||
*/
|
||||
|
||||
import dayjs from 'dayjs'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { useTagColorStore } from '@/stores/tagColors'
|
||||
import { useTaskStore } from '@/stores/tasks'
|
||||
import {
|
||||
breakdownByTag,
|
||||
buildHeatmap,
|
||||
completionCycle,
|
||||
computeActivitySummary,
|
||||
countCompletionsByDate,
|
||||
DEFAULT_HEATMAP_WEEKS,
|
||||
longestStreak,
|
||||
UNTAGGED_LABEL,
|
||||
type HeatmapLevel,
|
||||
} from '@/utils/activity'
|
||||
|
||||
/**
|
||||
* 只显示 周一 / 周三 / 周五,其余留空占位。
|
||||
* 七个标签全写会挤成一列噪音,GitHub 的惯例只标奇数行。
|
||||
*/
|
||||
const WEEKDAY_LABELS = ['一', '', '三', '', '五', '', ''] as const
|
||||
|
||||
/**
|
||||
* 强度色阶。必须是**完整类名字符串** —— Tailwind v4 构建时扫描源码,
|
||||
* 动态拼接(`bg-emerald-${level}00`)不会被生成,样式会静默失效。
|
||||
*
|
||||
* 用 emerald 而不是蓝色:项目里「完成」一律是绿色,和看板已完成列的语义一致。
|
||||
*/
|
||||
const LEVEL_CLASSES: Record<HeatmapLevel, string> = {
|
||||
0: 'bg-slate-100 dark:bg-zinc-800',
|
||||
1: 'bg-emerald-200 dark:bg-emerald-900',
|
||||
2: 'bg-emerald-300 dark:bg-emerald-700',
|
||||
3: 'bg-emerald-400 dark:bg-emerald-600',
|
||||
4: 'bg-emerald-500 dark:bg-emerald-400',
|
||||
}
|
||||
|
||||
const LEVELS: readonly HeatmapLevel[] = [0, 1, 2, 3, 4]
|
||||
|
||||
const store = useTaskStore()
|
||||
const tagColors = useTagColorStore()
|
||||
const { activeTasks } = storeToRefs(store)
|
||||
|
||||
/** 只展示前 6 个标签,再多就成一张读不完的清单了 */
|
||||
const TOP_TAGS = 6
|
||||
|
||||
/** 回收站里的任务不计入:软删除的语义是「这件事不算了」 */
|
||||
const counts = computed(() => countCompletionsByDate(activeTasks.value))
|
||||
const summary = computed(() => computeActivitySummary(counts.value))
|
||||
const heatmap = computed(() => buildHeatmap(counts.value, { weeks: DEFAULT_HEATMAP_WEEKS }))
|
||||
|
||||
const cycle = computed(() => completionCycle(activeTasks.value))
|
||||
const longest = computed(() => longestStreak(counts.value))
|
||||
|
||||
const tagBreakdown = computed(() => breakdownByTag(activeTasks.value).slice(0, TOP_TAGS))
|
||||
/** 条长按最大项归一;全为 0 时兜底 1,避免除零 */
|
||||
const tagMax = computed(() => Math.max(1, ...tagBreakdown.value.map((item) => item.count)))
|
||||
|
||||
const averagePerActiveDay = computed(() => {
|
||||
const days = summary.value.activeDays
|
||||
return days === 0 ? 0 : summary.value.total / days
|
||||
})
|
||||
|
||||
/** 小数只保留一位;超过 10 天再带小数就没意义了 */
|
||||
function formatDays(value: number): string {
|
||||
return value < 10 ? value.toFixed(1) : String(Math.round(value))
|
||||
}
|
||||
|
||||
const cycleLabel = computed(() => {
|
||||
const stats = cycle.value
|
||||
if (stats === null) return '—'
|
||||
if (stats.median < 1) return '当天'
|
||||
return `${formatDays(stats.median)} 天`
|
||||
})
|
||||
|
||||
const detailCards = computed(() => [
|
||||
{ label: '平均完成周期', value: cycleLabel.value },
|
||||
{ label: '最长连续', value: `${longest.value} 天` },
|
||||
{ label: '有记录', value: `${summary.value.activeDays} 天` },
|
||||
{ label: '平均每天', value: `${formatDays(averagePerActiveDay.value)} 个` },
|
||||
])
|
||||
|
||||
/** 标签条的颜色跟随标签圆点;未分类与未设色的标签用中性灰 */
|
||||
function barClass(tag: string): string {
|
||||
if (tag === UNTAGGED_LABEL) return 'bg-slate-400'
|
||||
return tagColors.dotClassOf(tag) ?? 'bg-slate-400'
|
||||
}
|
||||
|
||||
const cards = computed(() => [
|
||||
{ label: '今天完成', value: summary.value.today, unit: '个', dot: 'bg-sky-500' },
|
||||
{ label: '连续完成', value: summary.value.streak, unit: '天', dot: 'bg-emerald-500' },
|
||||
{ label: '本月完成', value: summary.value.thisMonth, unit: '个', dot: 'bg-violet-500' },
|
||||
{ label: '累计完成', value: summary.value.total, unit: '个', dot: 'bg-slate-400' },
|
||||
])
|
||||
|
||||
const bestDayLabel = computed(() => {
|
||||
const best = summary.value.bestDay
|
||||
if (!best) return null
|
||||
return `${dayjs(best.date).format('M月D日')} · ${best.count} 个`
|
||||
})
|
||||
|
||||
/** 热力图的读屏摘要:几百个格子逐个念出来只会变成噪音,聚合到容器上 */
|
||||
const heatmapLabel = computed(
|
||||
() =>
|
||||
`最近一年的完成热力图,` +
|
||||
`有 ${summary.value.activeDays} 天产生过完成记录,累计 ${summary.value.total} 个任务`,
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex min-h-0 flex-1 flex-col overflow-y-auto">
|
||||
<div class="mx-auto flex w-full max-w-4xl flex-col gap-5 p-4 md:p-6">
|
||||
<header class="flex flex-col gap-0.5">
|
||||
<h1
|
||||
class="text-xl font-semibold tracking-tight text-slate-900 md:text-2xl dark:text-zinc-100"
|
||||
>
|
||||
统计
|
||||
</h1>
|
||||
<p class="text-sm text-slate-500 dark:text-zinc-400">
|
||||
基于任务的完成时间统计,不含回收站里的任务
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<dl class="grid grid-cols-2 gap-3 md:grid-cols-4">
|
||||
<div
|
||||
v-for="card in cards"
|
||||
:key="card.label"
|
||||
class="rounded-xl border border-slate-200 bg-white px-4 py-3.5 shadow-card dark:border-zinc-800 dark:bg-zinc-900"
|
||||
>
|
||||
<dt class="flex items-center gap-1.5 text-xs text-slate-500 dark:text-zinc-400">
|
||||
<span class="size-1.5 rounded-full" :class="card.dot" aria-hidden="true" />
|
||||
{{ card.label }}
|
||||
</dt>
|
||||
<dd
|
||||
class="mt-1.5 text-2xl font-bold tracking-tight tabular-nums text-slate-900 dark:text-zinc-100"
|
||||
>
|
||||
{{ card.value
|
||||
}}<span class="ml-0.5 text-sm font-normal text-slate-400 dark:text-zinc-500">{{
|
||||
card.unit
|
||||
}}</span>
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<section
|
||||
class="rounded-xl border border-slate-200 bg-white px-4 py-4 shadow-card dark:border-zinc-800 dark:bg-zinc-900"
|
||||
>
|
||||
<div class="mb-3 flex flex-wrap items-baseline justify-between gap-2">
|
||||
<h2 class="text-sm font-medium text-slate-900 dark:text-zinc-100">
|
||||
完成热力图
|
||||
<span class="ml-1 text-xs font-normal text-slate-400 dark:text-zinc-500"> 近一年 </span>
|
||||
</h2>
|
||||
|
||||
<div class="flex items-center gap-1 text-[10px] text-slate-400 dark:text-zinc-500">
|
||||
<span>少</span>
|
||||
<span
|
||||
v-for="level in LEVELS"
|
||||
:key="level"
|
||||
class="size-[11px] rounded-[2px]"
|
||||
:class="LEVEL_CLASSES[level]"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span>多</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!--
|
||||
格子固定 11px(压到 8px 以下就分不清了),多出来的宽度交给列间距:
|
||||
周列容器用 `flex-1` 占满剩余宽度,`justify-between` 把富余空间均分到 52 个间隙。
|
||||
宽屏下间隙从 3px 涨到 4.1px,整块热力图铺满卡片、左右不再偏斜。
|
||||
|
||||
`pr-[15px]` 是左侧星期标签列的镜像(`w-3` 12px + 父级 gap 3px):
|
||||
补上它,方块阵列距内容左右边界各 15px,视觉才真正对称;
|
||||
同时也给最后一列今天那格的 `ring-1` 留出位置 —— ring 画在元素外侧 1px,
|
||||
贴到 overflow 容器边界上会被裁掉(实测超出 1px,右侧描边缺一条)。
|
||||
|
||||
不能靠 `mx-auto` 居中:`inline-flex` 是行内级元素,auto margin 在水平方向
|
||||
按 0 处理,实测 814 的容器里左边贴 15px、右边空 60px,整块偏左。
|
||||
-->
|
||||
<div class="overflow-x-auto pt-[1px] pb-1" role="img" :aria-label="heatmapLabel">
|
||||
<div class="flex gap-[3px] pr-[15px]">
|
||||
<div class="mt-[17px] flex shrink-0 flex-col gap-[3px]" aria-hidden="true">
|
||||
<span
|
||||
v-for="(label, index) in WEEKDAY_LABELS"
|
||||
:key="index"
|
||||
class="h-[11px] w-3 text-[10px] leading-[11px] text-slate-400 dark:text-zinc-500"
|
||||
>
|
||||
{{ label }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 53 列 × 11px + 52 × 3px = 739px:容器窄于它时保持最小间隙,让外层滚动 -->
|
||||
<div
|
||||
class="flex min-w-0 flex-1 justify-between"
|
||||
:style="{ minWidth: `${heatmap.length * 14 - 3}px` }"
|
||||
>
|
||||
<!--
|
||||
月份标签必须绝对定位:它是 whitespace-nowrap 的文字,宽度远超 11px 的格子,
|
||||
留在流内会把所在列撑宽 —— 实测 53 列会被撑到 828px,容器只有 814px,
|
||||
于是凭空多出一条滚动条。用 h-[14px] 的占位块保住行高即可。
|
||||
-->
|
||||
<div v-for="week in heatmap" :key="week.key" class="relative flex flex-col gap-[3px]">
|
||||
<span
|
||||
v-if="week.monthLabel"
|
||||
class="absolute top-0 left-0 h-[14px] text-[10px] leading-[14px] whitespace-nowrap text-slate-400 dark:text-zinc-500"
|
||||
>
|
||||
{{ week.monthLabel }}
|
||||
</span>
|
||||
<div class="h-[14px]" aria-hidden="true" />
|
||||
|
||||
<div
|
||||
v-for="cell in week.cells"
|
||||
:key="cell.date"
|
||||
data-role="heatmap-cell"
|
||||
:data-date="cell.date"
|
||||
:data-count="cell.count"
|
||||
:data-level="cell.level"
|
||||
class="size-[11px] rounded-[2px]"
|
||||
:class="[
|
||||
cell.future ? 'invisible' : LEVEL_CLASSES[cell.level],
|
||||
cell.today ? 'ring-1 ring-slate-400 dark:ring-zinc-500' : '',
|
||||
]"
|
||||
:title="`${cell.date} · 完成 ${cell.count} 个`"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="mt-3 text-xs text-slate-400 dark:text-zinc-500">
|
||||
<template v-if="summary.bestDay">最忙的一天:{{ bestDayLabel }}</template>
|
||||
<template v-else>还没有完成记录 —— 把任务拖到「已完成」,这里就会亮起来。</template>
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<div class="grid gap-3 md:grid-cols-2">
|
||||
<section
|
||||
class="flex flex-col rounded-xl border border-slate-200 bg-white px-4 py-4 shadow-card dark:border-zinc-800 dark:bg-zinc-900"
|
||||
>
|
||||
<div class="mb-3 flex items-baseline justify-between gap-2">
|
||||
<h2 class="text-sm font-medium text-slate-900 dark:text-zinc-100">时间花在哪</h2>
|
||||
<span class="text-[10px] text-slate-400 dark:text-zinc-500">
|
||||
按标签 · 前 {{ TOP_TAGS }} 项
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p v-if="tagBreakdown.length === 0" class="py-8 text-center text-xs text-slate-400 dark:text-zinc-500">
|
||||
还没有带标签的完成记录
|
||||
</p>
|
||||
|
||||
<ul v-else class="flex flex-col gap-2.5">
|
||||
<li
|
||||
v-for="item in tagBreakdown"
|
||||
:key="item.tag"
|
||||
data-role="tag-bar"
|
||||
:data-tag="item.tag"
|
||||
:data-count="item.count"
|
||||
>
|
||||
<div class="flex items-baseline justify-between gap-2 text-xs">
|
||||
<span class="flex min-w-0 items-center gap-1.5 text-slate-600 dark:text-zinc-300">
|
||||
<span
|
||||
class="size-1.5 shrink-0 rounded-full"
|
||||
:class="barClass(item.tag)"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span class="truncate">#{{ item.tag }}</span>
|
||||
</span>
|
||||
<span class="shrink-0 tabular-nums text-slate-400 dark:text-zinc-500">
|
||||
{{ item.count }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="mt-1 h-1.5 overflow-hidden rounded-full bg-slate-100 dark:bg-zinc-800">
|
||||
<div
|
||||
class="h-full rounded-full"
|
||||
:class="barClass(item.tag)"
|
||||
:style="{ width: `${(item.count / tagMax) * 100}%` }"
|
||||
/>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<p class="mt-auto pt-3 text-[10px] leading-relaxed text-slate-400 dark:text-zinc-500">
|
||||
一个任务有多个标签时,每个标签各计一次。
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section
|
||||
class="flex flex-col rounded-xl border border-slate-200 bg-white px-4 py-4 shadow-card dark:border-zinc-800 dark:bg-zinc-900"
|
||||
>
|
||||
<h2 class="mb-3 text-sm font-medium text-slate-900 dark:text-zinc-100">一些细节</h2>
|
||||
|
||||
<dl class="grid grid-cols-2 gap-x-3 gap-y-4">
|
||||
<div v-for="item in detailCards" :key="item.label">
|
||||
<dt class="text-xs text-slate-500 dark:text-zinc-400">{{ item.label }}</dt>
|
||||
<dd
|
||||
class="mt-1 text-lg font-semibold tracking-tight tabular-nums text-slate-900 dark:text-zinc-100"
|
||||
>
|
||||
{{ item.value }}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<p class="mt-auto pt-3 text-[10px] leading-relaxed text-slate-400 dark:text-zinc-500">
|
||||
完成周期取中位数 —— 一条躺了半年的任务就能把平均值拉偏,中位数描述的是「通常要多久」。
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<p class="text-xs leading-relaxed text-slate-400 dark:text-zinc-500">
|
||||
统计只根据任务上记录的完成时间推算。永久删除或清空回收站会连同那天的记录一起消失,
|
||||
因此这里的数字通常比实际做过的偏少 —— 宁可保守,也不维护一份会与任务对不上的计数。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
Reference in New Issue
Block a user