From 23fed50734cbba8f9d69d1cd2183c497832abc37 Mon Sep 17 00:00:00 2001 From: chenshoulin Date: Sun, 25 Oct 2026 01:14:19 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E7=BB=9F=E8=AE=A1?= =?UTF-8?q?=E8=A7=86=E5=9B=BE=EF=BC=88ADR-012=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 /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 的三入口断言。 --- README.md | 28 +- docs/adr.md | 93 +++++++ e2e/stats.spec.ts | 144 ++++++++++ src/components/AppNav.spec.ts | 4 +- src/components/AppNav.vue | 1 + src/router/index.ts | 6 + src/utils/activity.spec.ts | 492 ++++++++++++++++++++++++++++++++++ src/utils/activity.ts | 326 ++++++++++++++++++++++ src/views/StatsView.spec.ts | 301 +++++++++++++++++++++ src/views/StatsView.vue | 325 ++++++++++++++++++++++ 10 files changed, 1715 insertions(+), 5 deletions(-) create mode 100644 e2e/stats.spec.ts create mode 100644 src/utils/activity.spec.ts create mode 100644 src/utils/activity.ts create mode 100644 src/views/StatsView.spec.ts create mode 100644 src/views/StatsView.vue diff --git a/README.md b/README.md index 3758204..06a1d14 100644 --- a/README.md +++ b/README.md @@ -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 步,且仅存在于当前会话与当前标签页:刷新或换标签页即清空,不跨设备同步;另一标签页改动数据后,本页的历史会作废。 diff --git a/docs/adr.md b/docs/adr.md index 67f21ea..be44dbd 100644 --- a/docs/adr.md +++ b/docs/adr.md @@ -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 的已知限制,也写在统计页脚注里 —— + 用户看到数字变少时能立刻知道原因,而不是怀疑数据坏了。 diff --git a/e2e/stats.spec.ts b/e2e/stats.spec.ts new file mode 100644 index 0000000..5ef9243 --- /dev/null +++ b/e2e/stats.spec.ts @@ -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[0], title: string): Promise { + await page.locator('article').filter({ hasText: title }).locator('button').first().click() +} + +/** 本地日期 YYYY-MM-DD,与热力图的口径一致 */ +async function todayIso(page: Parameters[0]): Promise { + 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('当天') +}) diff --git a/src/components/AppNav.spec.ts b/src/components/AppNav.spec.ts index 86aebd6..ce25038 100644 --- a/src/components/AppNav.spec.ts +++ b/src/components/AppNav.spec.ts @@ -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 () => { diff --git a/src/components/AppNav.vue b/src/components/AppNav.vue index 51383a4..5041618 100644 --- a/src/components/AppNav.vue +++ b/src/components/AppNav.vue @@ -12,6 +12,7 @@ const store = useTaskStore() const LINKS = [ { to: '/', label: '看板' }, { to: '/today', label: '今日' }, + { to: '/stats', label: '统计' }, ] as const diff --git a/src/router/index.ts b/src/router/index.ts index f30a05e..cbc6dda 100644 --- a/src/router/index.ts +++ b/src/router/index.ts @@ -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: '/' }, ] diff --git a/src/utils/activity.spec.ts b/src/utils/activity.spec.ts new file mode 100644 index 0000000..3822b3f --- /dev/null +++ b/src/utils/activity.spec.ts @@ -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 { + 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) + }) +}) diff --git a/src/utils/activity.ts b/src/utils/activity.ts new file mode 100644 index 0000000..3cb8232 --- /dev/null +++ b/src/utils/activity.ts @@ -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 { + const counts = new Map() + + 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, + 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, 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, + 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() + 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): 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 +} diff --git a/src/views/StatsView.spec.ts b/src/views/StatsView.spec.ts new file mode 100644 index 0000000..b3ac316 --- /dev/null +++ b/src/views/StatsView.spec.ts @@ -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 { + 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 个') + }) +}) diff --git a/src/views/StatsView.vue b/src/views/StatsView.vue new file mode 100644 index 0000000..c2b2150 --- /dev/null +++ b/src/views/StatsView.vue @@ -0,0 +1,325 @@ + + +