# Tadado 详细设计说明 —— v1.x（Tadado2 · Tauri 版）

> 本文档按功能模块记录需求、实现方案和界面布局标注，用于快速项目重建和迭代参考。
> 「怎么跑 / 目录里有什么」看 [`desktop/README.md`](desktop/README.md)，
> 运行时 AI 指令参见 [CLAUDE.md](CLAUDE.md)，更新日志参见 [CHANGELOG.md](CHANGELOG.md)。
>
> **两条版本线各自维护自己的文档**：
>
> | 版本线 | 分支 | 形态 | 设计文档 |
> |:--:|---|---|---|
> | **v1.x（当前）** | `main` | Tadado2 · Tauri 2 | **本文件** |
> | v0.x | `archive/pyversion` | PySide6 | 那条分支上的 `DESIGN.md` |
>
> 两版**零数据共享**，但共享同一份**设计语言**（语义色、几何、暖灰双主题）与同一份**领域概念**
> （分区、优先级 P0–P3、状态、活动时间线）。v1.x 在几个地方**有意与 v0.x 分叉**，
> 分叉点在本文件里逐条写明。
>
> **路径约定**：本文件提到的源码路径**均相对 `desktop/`** —— 写 `src/data/db.ts` 时，
> 实际位置是 `desktop/src/data/db.ts`。
>
> 取归档分支上的东西：
>
> ```bash
> git show archive/pyversion:DESIGN.md                    # Py 版设计文档
> git show archive/pyversion:src/utils/design_tokens.py   # 语义色权威源
> git checkout archive/pyversion -- <路径>                 # 取单个文件或整目录
> ```

---

## 1. 总览

### 1.1 技术栈

| 层面 | 技术 |
|------|------|
| 外壳 | Tauri 2（Rust） |
| 渲染 | 系统 WebView2（Windows）+ TypeScript，无框架 |
| 构建 | Vite 8 · TypeScript 6（`strict`） |
| 存储 | SQLite（`tauri-plugin-sql`，feature `sqlite`） |
| 测试 | Playwright 冒烟（真 Chromium 点一遍）+ `tsc --noEmit` |
| 打包 | Tauri bundler（NSIS / MSI）+ 便携 zip |

**无框架是刻意的**：界面是「外壳 + 五个页面 + 几个浮层」，没有路由表要维护、没有虚拟 DOM 要调和。
`el()` 一个 DOM 助手（`shell/dom.ts`）就够把结构写清楚；换框架带来的收益（组件复用、响应式）在这里
用不上，代价（构建链、抽象层、首帧）却是实打实的。

**无运行时依赖**：产物是单文件 exe，用户机器上不需要 Node、Python 或任何运行库 —— 这是相对 Py 版
（PyInstaller 要打包整个 Python 运行时）最直接的收益。

### 1.2 分层

```
desktop/
index.html                 应用骨架：标题栏 + rail + 主区 + 设置抽屉（静态壳）
src/main.ts                入口：只按顺序调用各模块的 mount，不放业务逻辑
src/styles.css             样式入口：按依赖顺序 @import 五层 css

src/data/                  数据层（不认识 DOM）
  types.ts                 领域类型：TaskStatus / Urgency / MonthDay / At / Activity / Task
  time.ts                  时间基准 TODAY、dayNumber、时刻与文案换算
  db.ts                    持久化：SQLite / localStorage 双后端、读写、backend 状态机
  schema.ts                存储版本 + 迁移链 + 读入口归位
  store.ts                 变更广播 dataChanged / onDataChange、逾期重算、完成后归档、完成日推算
  mock.ts                  种子数据：演示空间 100 条
  partitions.ts            分区列表 / 当前分区 / 默认分区
  tags.ts                  标签规则（上限 3、未分类）
  markdown.ts              任务行写法：parseTasks（入）/ taskToMarkdown（出）
  export.ts / xlsx.ts      导出：三种格式的同一份数据；xlsx 自写 zip
  timeline.ts              时间轴档位与窗口算数

src/shell/                 外壳能力（跨页面、不认识业务）
  dom.ts / toast.ts        极简 DOM 助手、提示条
  theme.ts / scheme.ts     主题（light/dark/sys）、热力图配色
  titlebar.ts / window.ts  标题栏装配、窗口能力（Tauri 环境降级）
  nav.ts / router.ts       rail 渲染与页面挂载、页面切换状态
  partition.ts             分区切换器
  settings.ts              设置抽屉
  update.ts / links.ts     应用内更新（检查 / 下载安装的门面，走 tauri-plugin-updater）、外链与复制（下载渠道常量都在这儿）
  lock.ts                  分区口令与空闲锁定
  hotkey.ts / trayBridge.ts / autostart.ts   全局热键、托盘事件、开机自启
  rollover.ts              跨日续跑：托盘常驻过了午夜，让「今天」重新算一遍（重载）
  confirm.ts / prompt.ts   二次确认、单行输入浮层
  panels.ts                右侧抽屉互斥与「点外部收起」
  menu.ts / seg.ts / dateTime.ts             下拉、分段、日期时间输入
  download.ts / exportMenu.ts                落文件、「导出 ▾」按钮

src/pages/                 页面层
  registry.ts              PageId / PageSpec / PAGES —— 导航与快捷键的唯一来源
  index.ts                 PAGE_VIEWS：PageId → 实现（与 registry 分开，避免循环依赖）
  shared.ts                跨页共用的常量与展示换算
  overview.ts / tasks.ts / graph.ts / activity.ts / manage.ts
  taskDrawer.ts / taskForm.ts                维护抽屉、定义表单
  focus.ts                 跨页「定位到任务」的单向请求
  pager.ts                 分页器（三张表共用）

src-tauri/
  src/lib.rs               插件注册（含 updater）、托盘、app_exit 命令、单实例
  src/main.rs              只调 desktop_lib::run()
  tauri.conf.json          窗口、打包与 updater（endpoints / pubkey / passive）配置
  capabilities/default.json 权限清单（白名单，逐项授予）
  windows/installer.nsi    定制 NSIS 模板（升级页 + 被动模式原地升级）
scripts/updater-manifest.mjs 构建后生成 latest.json（发布用；缺包或缺签名即失败）
```

**依赖方向是单向的**：`data ← shell ← pages`。`data/` 不许碰 DOM，`shell/` 不认识业务对象，
`pages/` 只经由 `shell/` 提供的能力动窗口与浮层。这条线撑住了「页面可以整体替换」这件事 ——
本文档后面每一节的实现方案都依赖它。

### 1.3 启动装配顺序（`main.ts`）

`boot()` 的调用顺序**不是随手排的**，每一处都有理由：

| 序 | 调用 | 为什么是这个位置 |
|:--:|------|------------------|
| 1 | `initTheme()` | 主题先落 `data-theme`，否则首帧闪白 |
| 2 | `initScheme()` | 热力图色阶同理，要在画格子之前定好 |
| 3 | `await bootStore()` | **数据先装好再画页面**：反过来的话第一帧画的是种子数据，存档一到位整屏跳一次 |
| 4 | `await bootPartitions()` | 先读存档再用 —— 用户自己分过的区不能被内置那四个盖回去 |
| 5 | `await bootLock()` | 排在画页面之前：要先决定这一屏要不要挡一层锁屏 |
| 6–9 | `mountTitlebar()` `mountNav()` `mountPartition()` `mountSettings()` | 骨架装配 |
| 10 | `storageIssue()` → `toast` | 存储有问题在这里**说出来**，不静默 |
| 11 | `mountTrayBridge()` | 托盘事件的前端一半 |
| 12 | `await initWindowState()` | 对齐窗口真实状态（置顶 / 最大化），避免按钮显示与实际不符 |
| 13 | `if (!isTauri()) return` | 浏览器预览到此为止，下面都是宿主能力 |
| 14 | `await setupHotkey()` | 失败要**告警**：热键挂了、窗口收起后唤不回，用户会以为应用死了 |
| 15 | `watchDayRollover()` | 托盘常驻会**开着过夜**：过了午夜得把「今天」重新算一遍（见 §5.2） |

### 1.4 与 Py 版（v0.x）的关系

- **2026-09-15 分家**：PySide6 版连同 `src/` `tests/` `scripts/` `resources/themes/` `pyproject.toml`
  全部归档到 **`archive/pyversion`** 分支；`main` 只推桌面端。两条线**各自维护自己的代码与文档**。
- **零数据共享**：两版数据目录不同（v1.x 在 `%APPDATA%\com.tadado.app\`，v0.x 是 `resources/tadado.data`），
  装 v1.x 不会动旧版。旧数据走**导出 → 转换 → 批量新建**（见 §7.1）。
- **`identifier` 不能改**：`com.tadado.app` 决定数据目录。改它等于让用户现有的库、窗口位置、设置
  全部「消失」。同理 `tadado.data` 这个库名、localStorage 的键、Cargo 的 package name（开机自启的
  注册项按它走）都保持原样。

---

## 2. 视觉与样式

### 2.1 两个权威源

颜色和几何**不许在这里自由发挥**：

| 内容 | 权威源 |
|------|--------|
| 语义色、字体栈 | `archive/pyversion:src/utils/design_tokens.py`（随 Py 版归档，见文件头的取法） |
| 圆角、控件尺寸、阴影、动效曲线 | [`resources/ui-mockup/tadado-2.0.html`](resources/ui-mockup/tadado-2.0.html) |
| 窗口形态、导航骨架 | `archive/pyversion:DESIGN.md` 的 2.12 / 1.3.1 两节 |

`src/styles/tokens.css` 只做一件事：把上面两者翻译成 CSS 变量。**不要新增颜色** ——
一旦出现字面量色值，主题（亮/暗）就会在某处漏掉一块。

### 2.2 五层样式与依赖方向

`src/styles.css` 按顺序 `@import`：

```
tokens   →  CSS 变量（语义色 + 几何 + 字体 + 热力图色阶）
base     →  reset / 排版 / 滚动条 / 焦点环 / 提示条
shell    →  应用骨架：标题栏 + rail + 主区 + 抽屉
controls →  通用控件：按钮 / 胶囊 / 分段 / 输入 / 下拉 / 卡片 / 开关
pages    →  五个视图各自的部件 + 维护抽屉内容
```

**方向只能是 `tokens → base → shell → controls → pages`**。改样式前先确认改的是哪一层：
变量不许塞进控件层，控件的几何不许在页面层重写。

`pages.css` 有两处**刻意改名**，别照着原型搜索：原型的 `.tile.tl` → `.tile.clickable`（原名在 CSS 里
是没有意义的缩写），原型的全局 `table` → `.tabwrap`（全局选择器会波及抽屉里的表格）。

### 2.3 一屏到底（布局的总原则）

**主区不滚**。五个页面各自撑满窗口：固定的块（指标卡、热力图、工具行）留在原地，**会长的那一块**
（表格 / 列表）在卡片内部吃掉剩余高度并在那里滚。这样翻页与筛选时，页头、卡片头、分页器不会被滚走。

高度是这样传下去的：

```
.main{overflow:hidden}          主区不滚
  └ .page{height:100%; display:flex; flex-direction:column}
      └ .inner{flex:1; min-height:0}
          ├ .ph{flex:none}      页头留在原地
          └ .page-body{flex:1}  固定块 flex:none，会长的那块 flex:1
```

**四个坑**（都是实测踩出来的，改布局前先读）：

1. **grid 的行高默认由内容决定**。列表装了 20 行就把行高顶到 700px，容器被压扁后行高照旧 ——
   多出来的部分顶着整页滚（总览实测溢出 186px）。要 `grid-template-rows: minmax(0,1fr)`
   把行高钉在容器高度上，卡片再靠内部的 flex 链把列表压进剩余空间。
2. **无名 div 会断链**。块级容器不做 flex 分配，链断在那里 `flex:1` 全落空。总览那几块外面
   原来包着一层没有样式的 div，因此给它一个 `.ov-page` 才把链接上。
3. **卡片等高之后，绝对定位的元素会盖住下一张卡**。时间轴上的气泡多出几像素就越界，不只是难看 ——
   **被盖住的按钮点不到**（e2e 就卡在那一步重试到超时）。撑满场景的 `.card-b` 要 `overflow:hidden`。
4. **每一层都要 `min-height:0`**。flex 子项默认不会被压矮，漏一层这条链就断在那一层。

**不要用 `calc(100vh - Npx)` 估可视高度**：那是一串写死的层高，多一层少一层就估错，估错一次
就有一截内容被切掉。任务页原来就是这么干的（`calc(100vh - 268px)`），现在换成了同一条 flex 链。

**分页与屏内滚并存**：每页条数仍是分页器上的档位（`PAGE_SIZES = [20, 30, 50, 100]`），
屏内滚只是兜底 —— 一屏装 13 行时看第 14 行要滚，但**不必滚整页**。

### 2.4 窗口形态

| 参数 | 值 | 理由 |
|------|-----|------|
| 尺寸 | 1180 × 760 | 一屏装得下五个页面的主内容 |
| 最小尺寸 | 1050 × 680 | 低于这个尺寸，任务页的列吸附会开始互相压 |
| `decorations` | `false` | 自绘标题栏（样式与原型对齐） |
| `resizable` | `true` | **保留系统缩放热区与 Aero Snap** —— 自绘标题栏不能连边缘拖拽一起丢掉 |
| `transparent` | `false` | 页面有实底，透明窗口没有收益且合成更贵 |
| `alwaysOnTop` | `false` | 启动不置顶，由标题栏的图钉按钮控制 |
| 标题栏拖拽 | `data-tauri-drag-region="deep"` | 让子元素所在区域也能拖 |
| 关闭按钮 | 收起常驻 | 真正退出走托盘菜单的 `app_exit` |

`additionalBrowserArgs` 里关掉了 WebView2 的若干特性（`msWebOOUI` / `msPdfOOUI` /
`msSmartScreenProtection` / `CalculateNativeWinOcclusion`）并禁用了后台节流 ——
关前三个是为了去掉页面里的浏览器痕迹，最后一个是为了让窗口被遮挡时计时器不被降频。

---

## 3. 数据层

### 3.1 存储形态

```sql
CREATE TABLE IF NOT EXISTS tasks (
  id TEXT PRIMARY KEY NOT NULL,
  partition TEXT NOT NULL,
  archived INTEGER NOT NULL DEFAULT 0,
  status TEXT NOT NULL,
  start TEXT NOT NULL,      -- MM-DD
  end   TEXT NOT NULL,      -- MM-DD
  data  TEXT NOT NULL       -- 其余业务字段整条 JSON
);
CREATE TABLE IF NOT EXISTS kv (
  key TEXT PRIMARY KEY NOT NULL,
  value TEXT NOT NULL
);
```

**只有六个字段立在列上**（够用来筛选、排序、分组），其余业务字段整条塞进 `tasks.data`。
这么分的理由：列一旦立起来就要跟着迁移走，而业务字段还在长（加一个字段就得写一次迁移，
不值当）。代价是「列里的结构」没有版本号管 —— 由 §3.3 的读入口负责。

写策略是**全量覆盖**（先 `DELETE FROM tasks` 再逐条 `INSERT`，防抖 400ms）。

### 3.2 版本与迁移

- 版本号走 `PRAGMA user_version`，**跟数据库文件走**（和 Py 版同一套来源）。没有版本号就等于
  每次启动都可能对着一个未知结构跑 —— 「现在能用」不等于「结构受控」。
- `MIGRATIONS: {from, to, sql?, run?}[]` 是**线性链**，启动时读当前版本顺序跑完写回。
  规矩照搬 Py 版 `src/models/migrations.py`：**只能加不能改**（只 `ALTER TABLE ADD COLUMN`，
  不删列、不改列类型）。
- 链的第一环 `0 → 1` 就是「确保这套表在」。**老库的 `user_version` 读出来也是 0**，
  于是它一启动就被带上版本号、数据一条没动 —— 这是存量库零成本升级的路子。
- `setVersion` 写完 PRAGMA 会**在同一条连接上读回核对**，不一致直接抛。PRAGMA 万一被忽略
  是静默的，而机制静默退化（每次启动重跑一遍迁移）要等某天真加了破坏性迁移才会暴露 ——
  那时它已经在用户的库上跑过好几遍了。

> **一条教训**（值得单独记）：曾经我直接读库文件头判断 `user_version`，读到 0 就断定「PRAGMA 写不生效」，
> 还把版本号搬去了 kv 表。结论是错的 —— 应用当时还开着，最新写入躺在 `-wal` 里没 checkpoint。
> **外部读到的值可能是陈旧视图，别拿它去推翻一个还在运行的进程。**

浏览器预览没有 SQLite，版本号落在 localStorage 的 `tadado.schema.version`，但**迁移链是同一条**，
所以 e2e 能真的把迁移跑一遍。

### 3.3 读入口归位（`normalizeTasks`）

`tasks.data` 里的结构版本号管不到，只能靠读入口：缺字段补默认、类型不对的救回来、
旧格式（活动时刻曾是「昨天 17:20」这类展示串）换成时间戳、认不出的活动类型退成 `log`、
连 `id` 或标题都没有的整条丢掉并计数（**不静默**，控制台有记录）。

原来这里是 `JSON.parse(...) as Task` —— **断言不是校验**：少一个字段就是 `undefined` 一路带到界面
（`tags.join()` 直接抛）。同类处理还有 kv 值的取证：`asStringRecord` / `asNumberRecord` /
`asArchiveRecord` / `asPartitionList`，形状不对就当没设过，而不是读出来直接用。

⚠️ **归档档位为什么单独一份 `asArchiveRecord`**：它这一路有**负数**档位（`-1` = 立即，见 §4.10），
而 `asNumberRecord` 是按「分钟数」写的、把负数当脏数据丢掉。两者共用过一阵，结果是「立即」写下去、
读回来变成 `undefined` —— 启动时被当成「这个分区从没设过」，于是被 §4.10 那条老库冻结写成「不归档」。
**「什么值算合法」这个知识属于消费它的那条路**，不是一份通用的「是个数字就行」。

### 3.4 故障不降级

`db.ts` 的 `backend` 有四个取值，其中一个是**终态**：

| 值 | 含义 | 行为 |
|----|------|------|
| `sqlite` | 宿主在、库打开成功 | 正常读写 |
| `local` | 不在 Tauri 里（浏览器预览） | 降级 localStorage |
| `error` | **宿主在、库却打不开** | **终态**：不降级、不重试、不写入 |
| `unknown` | 初始值 | —— |

判断顺序是**先看有没有宿主，再看库能不能开**。以前那个 `catch` 把这两种情况当成同一件事，
一律退回 localStorage —— 后果是用户的库静静地不被读取、界面一个字不说、此后所有写入都落到
另一个地方（数据被劈成两半，而表面上一切正常）。

配套两道闸：`readIssue` 非 null 时调用方**不能**当空库处理（`bootStore` 只清内存 + 广播，不播种，
否则「读不出来」会被种子数据**顶替**掉，看起来像正常）；读失败后 `writeBlocked` 禁止写入 ——
少了这条，「坏存档原样留着」只是句好话：界面继续跑，用户随手一点就触发 `dataChanged` 全量覆盖，
那份还能人工捞回来的存档当场没了。外壳用 toast 把原因说出来。

### 3.5 kv 的 key 清单

| key | 用途 |
|-----|------|
| `archive.afterDays` | 各分区的**完成后归档**档位（`{分区id: 天数}`：`0` = 不归档 / `-1` = 立即 / `N` = 完成后 N 天。**负数编码见 §4.10** —— 老数据里 `0` 就是「关」，沿用才不会把老用户的设置读歪）。新建的分区由 `store` 订阅分区列表变更时补成「立即」并**落盘**（见 §4.10） |
| `partitions.list` / `partitions.default` | 分区列表、启动进哪个分区 |
| `lock.passwords` / `lock.idleMinutes` | 各分区口令、空闲锁定分钟数 |
| `tadado.tasks.v1` / `tadado.kv.` / `tadado.schema.version` | **仅浏览器后端**：任务存档 / 设置 / 版本号 |

### 3.6 演示数据与分区

- 样例数据**只有一份，全部落在演示空间**：100 条 = 28 条原型手写（与 `resources/ui-mockup/tadado-2.0.html`
  逐字一致，方便并排比对）+ 72 条按序号**确定性**生成（随机会让「刚才那条去哪了」变成没法回答的问题）。
  生成器同时是「一屏画多少条」这类上限的真实输入。
- **默认分区就是演示空间**：否则开机进「工作」是一片空，看起来像数据没加载。
- **其他分区交付时为空**。空分区不是「数据没加载出来」，而是「这里本来就还没东西」。
- 早先种子按标签匀在四个区（「每区都得有东西可看」），结果是每区都只有几条、看不出规模、压测也压不出东西。
  老库里散出去的种子由 `store.ts` 的 `adoptSeedPartitions` 在启动时搬回来 —— **只动种子 id**，
  用户自己建的任务一条都不碰。光改种子只对全新安装成立，归位才让这个口径在任何一台机器上都成立。
- `TODAY` 读**真实时钟**；`DEMO_TODAY = [9, 12]` 只是**样品本身的排布锚点**（活动里的相对日期都相对它写）。
  两者混用会导致「今天」出现两种口径。

---

## 4. 功能模块

### 4.1 总览

**需求**：一眼看出「今天要做什么」与「最近发生了什么」。

**布局**：问候条 → 五张指标卡 → 下半页**两列**。

- 右列整条归**近期活动**（分页）：它就是这页最需要空间的那张表。
- 左列是「今天」：**焦点时间轴** + **优先级分布**。

**近期活动按任务聚合**（2026-09-20）：**一个任务一行**，行内给的是它**最新**的一条活动，
后面标出「共 N 条」。以前是一条活动一行 —— 同一个任务连着记三条进展就占三行、标题重复
三遍，扫一眼看见的其实只是「有个任务动了很多次」，而它明明一行就说得清。
**折叠而不报数同样不行**：「动过一次」和「动过八次、只看见最后一条」在这一行上会长得
一模一样，那折叠就成了丢信息。排序按**最新一次活动**倒序（聚成任务之后，「每条活动的
时间」这个排序键就没有了）；完整过程在抽屉的时间线里，点这一行就到那儿。
（焦点时间轴随后也改成了同样的按任务聚合，见下。）
**这张卡有上限：只摆最近 50 个任务**（`FEED_LIMIT`，2026-09-20 用户定的 —— 「只想显示
近期的 50 条，不想显示太多」）。上限是这张卡的**定位**：一眼看最近发生了什么；想翻遍历史
（按标签、按时间范围、带分页）去活动分析页。截断发生在聚合排序**之后**，所以「最近的 50」
就是真的最近 50 个动过的任务；分页器只在**这 50 条以内**翻。

聚合之后这张卡会出现两个不同的单位，**数字要少报、单位要写清**（2026-09-20 用户报的）：
卡片头的后缀给的是**窗口**而不是总数 —— `按最近活动倒序 · 前 50 个任务`。原来那里写
「共 88 个任务」，88 是聚合后的任务总数，会被读成「这一屏显示了 88 条」，跟每屏行数
（分页器档位 20/30/50，默认 **50** = 上限，见 §4.7）不是一回事；空表时不带这句。
分页器同样**只报范围**（「第 1–50 个任务」），不带「共 N」。每行右端那个「共 N 条」是
**另一个单位**（这一个任务的活动条数），两者不要并排比对。

**为什么分两列**：原来四块纵向堆叠，常见窗口高度下装不下 —— 近期活动只剩三行（实测列表区 100px），
要翻页得先滚。分列之后左列两块正好填满，近期活动列表从 100px 涨到 404px。

**焦点时间轴**是这页最花心思的一块，几条判据都是踩出来的：

- **只列这一天的活动记录**（含「新建任务」那一条），没动静的任务不占位置。它回答的是
  「**发生了什么**」，不是「该做什么」——后者归任务页与那排指标卡。
- **气泡也是按任务聚合**（2026-09-20）：一个任务占一个位置，写作「任务名 `+N`」（N = 它
  这一天更新了几条，绿字 `.tdt-cn`）。最初写的是活动原文，后来改成「任务名 · 更新了 N 条
  记录」，还是太长 —— 气泡是轴上的**标记**，一句完整的话会把整条轴挤满，数字才是重点。
  明细仍在抽屉的时间线里，点气泡就到。
- **「已过期未完成」与「逾期」是同一个口径**（2026-09-20 收紧）：这一栏的判据就是
  `status === "overdue"`（`store.refreshOverdue` 的产物）。以前写「未完成 && end < 今天」，
  把「进行中但已过期」也算进去（系统**故意不动** doing，见 store），于是栏上报 47、点
  「等 N 个」过去逾期清单只有 40 —— 两个数各自都没算错，摆在一起就像数据坏了。收进同一
  个谓词后：这一栏 = 「逾期」指标卡 = 任务页的逾期筛选，三处同一个数（与 `isDueToday`、
  `isOngoing` 同一条「一个谓词多个消费方」的规矩）。
- **逾期一多，这一栏只摆前 3 条，其余折成「等 N 个」**（2026-09-20）：它是**提示**不是
  清单，摆全了就把上面的轴挤没了。两个前提：① N 是**总数**（中文「A、B、C 等 N 个」的 N
  含前面摆出的，不是减出来的剩余数）；② 折掉的部分**有去处** —— 「等 N 个」可点，落到
  任务页的逾期筛选，且那里的数与栏上的总数相等（e2e 把这个等式量住）。总条数也仍在
  卡片头「过期 N 项未清」里 —— 折的是位置，不是数。
- 判据**不能**是「起止日期盖住今天」：那样结束在 9/12 的任务会杵在 9/16 的轴上写着 08:30，
  而旁边卡片写着「今日到期 2」—— 两个数各自都没算错，摆一起就是同一个「今天」两种口径。
- 「今日到期」加了 `due !== null` 判据：迁移来的旧数据里有一批**没有截止**的任务，它们的 `end`
  只能落今天，只看 `end` 会把「没有这个信息」显示成一个具体的日子。
- **气泡挤在一起时同侧自动多开一条道**（`spreadPills`）。分道只能在进了文档之后做 —— 气泡的
  `left` 是百分比，真实像素要等容器有宽度才量得出来；量不到（页面不可见）就保持原样。
  **不采用「重合就切甘特」**：甘特的粒度是**天**、当日轴的粒度是**分钟**，今天的十几条活动切过去
  全落在同一格，照样叠着，还顺带把时刻弄丢了。重合是「同一粒度里放不下」，解法只能是加道。
- 轴的基准高度由 CSS 与 JS **共用一个常量**（`AXIS_H`）：多开的道是在这个基准上往外加的，
  两处对不上就会要么压着气泡、要么白白空一截。
- 轴的量程固定 **06:00–24:00**，「现在」标记读真实时钟。**算出来的百分比必须钳在 0–100**：
  凌晨的「现在」是负数（01:00 → -27.8%），不钳就会画到轴的左边之外 —— 压在别的区块上或者
  被裁掉，看着像页面坏了。钳到 0 之后它贴在左端，标题里仍写着真实时刻（「现在 01:00」），
  信息不丢。对气泡没有影响：它们本来就在 `pct < 1 || pct > 99` 时转到轴下面那条去。

**五张指标卡都是入口**（今日到期 / 逾期 / 进行中 / 已完成 / 归档）：卡上的数字就是点开后的
**筛出总数**。`tile()` 里「可点 / 悬停提示 / 点击监听」三件事同源（给了 `onClick` 才都成立），
所以漏传 `onClick` 的后果是**整张卡静默失效**：数字摆在那儿，点上去没反应，页面上也没有任何
地方说得出为什么。「今日到期」就这么漏过一次（2026-09-20 报的），因此有三条规矩：

- 判据抽成 `pages/shared.ts` 的 **`isDueToday(task)`** —— 卡片数它、任务页那条「今日到期」筛选
  筛它，**同一个函数**。两处各写一份，就是给「卡片上写 1、点进去 0 条」留门；
- 任务页因此多一枚**不属于状态**的筛选 chip（见 §4.2）；
- e2e 的判据改成**遍历界面上所有的 `.tile`**，不再是一张写死的清单：有几张卡就验几张。
  写死清单正是它放走这次问题的原因（原来只列了「逾期 / 进行中 / 已完成」三张）。

**⚠️「待办」这一档删掉了**（2026-09-21 用户定的：「默认就是进行中」）。这条翻过一次面，
两段历史都留着：

- 2026-09-20 先把「进行中」做成**合并口径（待办 + 进行中）** —— 理由是「待办只是还没动手，
  它同样是手上没做完的活儿」，当时只数 `status === "doing"` 会让今天刚动过的那条不进卡
  （卡上写 2、点进去 1）；
- **2026-09-21 索性把那一档删了**：既然**统计口径**上早已合一，留着它只剩「多按一次状态按钮」
  的成本，而且它是「用户可选状态」里唯一一个**不代表任何实际差别**的值（新建即进行中、
  写一条进展也是进行中）。md 写法上 `[ ]` 仍然收（算进行中），见 §4.8。

判据仍抽在 `pages/shared.ts` 的 **`matchesStatus(task, filter)`**：总览那张卡、任务页那排 chip、
管理页的筛选、图谱页的「进行中」过滤 —— **四个消费方共用**（与 `isDueToday` 同一条理由，
各写一份就是给「卡上写 41、点进去 17」留门）。它以前是「一个合并口径 + 三个一对一」的混合体，
**现在是一对一**（那个 `isOngoing` 包装随之删掉）。
三张状态卡仍然把未归档任务**干净切开**：逾期 / 进行中 / 已完成，互不重叠。
**逾期不并进「进行中」**：它有自己的一张卡，两张卡数同一批等于把一件事说两遍，还会把
「最早逾期」这个更急的信息挤掉。

**第五张「已归档」数的是归档的那批**（2026-09-21）。其余四张都只数**未归档**。这张卡的来历是
一个真问题：默认「完成后归档＝立即」（§4.10）之下，「完成」与「归档」是**同一瞬间** —— 勾完
「已完成」，它就离开任务页、归档数 +1，而「已完成」那枚数字**先加后减**（同一帧里），看着像
什么也没发生，其实它只是换了个地方。把归档数摆出来，两处数字此消彼长，收走的东西就是
**看得见**的，不是凭空少掉的。

它也是唯一**不落在任务页**的数字卡：已归档的任务在任务页根本不列（归档的意思就是「从「任务」
界面收走」，见 §3.6 的 `activeTasks`），所以点它去**任务管理页的「已归档」**—— 那页是唯一能
看见这批的地方（§4.5）。这是 e2e 那条「遍历所有 `.tile`」的唯一例外，判据因此单独写：卡片数 =
那页筛出的总数，且落点那枚 chip 必须亮着「已归档」。

**归档不改其它卡的口径**：四张卡仍是未归档（`activeTasks`），归档单独一张 —— 这样「已完成掉数」
永远能对上「归档涨数」，不必把归档并进别的卡（并进去的话，那几张卡点开是任务页那批 = 未归档，
立刻变成「卡上 19、点进去 18」）。活动分析的口径本来就含归档（源码里写明「归档只是列表里不再
显示」）。

**已完成 / 归档 / 未归档三者的关系**（2026-09-21 用户问的）。`status`（进行中 / 已完成 /
逾期）与 `archived` 是**两个正交的字段**：前者说「这件事到哪一步了」，后者说「它在不在当前
工作面」。所以**「归档」不是一种状态** —— 取消归档后它的 `status` 一点没变，归档也不改 `status`
（种子里那条样例正是「已完成 + 已归档」：`ref`「旧版重构收尾」）。

- 三张状态卡把**未归档**那批干净切开（逾期 / 进行中 / 已完成，互不重叠）；
- 归档卡是它们的**补集**：`未归档 + 归档 = 本分区全部`（`activeTasks()` + `archived` 就是
  `partitionTasks()`）。演示数据上算一遍（删掉「待办」、逾期口径放宽成「未完成 + 过期」
  之后的实测值）：48 + 32 + 0 = 80，加归档 20 = 100；
- **「今日到期」不参与这个等式**：它是**截止日**维度的卡，与状态卡有交集（今天到期的可能是
  逾期的，也可能是进行中的）；
- 自动归档只收 `status === "done"` 的（判据见 §4.10），所以**自动**进去的一定是已完成；
  **手动**归档（管理页那一排按钮）任何状态都能进 —— 归档那批里不一定全是已完成。
  ⚠️ 判据没变，但**「已完成」的产生路径多了一条**（2026-10-16）：进度拖满 100% 会自动置为
  已完成、退回 100% 以下会自动退回进行中 —— 进度与状态是**同一个口径的两面**（见 §4.10）。
- 生命周期上的两处相互影响：① `refreshOverdue` **只豁免 `done`**（2026-09-21 改的口径：以前
  `doing` 也豁免 —— 那是在「待办」还在时写的，删掉它之后继续豁免就**没有任何任务会逾期**了，
  那张卡会永远归零。现在：**逾期 = 未完成 + 过了截止**，「进行中」= 未过期且未完成）；
  ② 手动「取消归档」只在**本次会话**有效（`store` 的 `restored`），
  下次启动若自动归档判据仍成立，它会被再收一次 —— 想改成持久的话得把 `restored` 落进 kv；
  ③ **把已归档的任务重新打开 ⇒ 它离开归档、回到工作面**（2026-10-17 用户报的）。判据是
  「**上一次广播时是已完成、这一次不是**」（`store` 的 `refreshReopened`），不是「非已完成就
  取消归档」—— 后者会把用户**手动归档**的进行中任务在下次 `dataChanged` 时偷偷放回来（管理页
  允许任何状态进归档，见上一条）。重新打开后不记 `restored`：状态已不是已完成，自动归档本就不
  会收它；若又拖满 100%，仍应按档位（立即 / N 天）当场归档。

副标那句「其中 N 个今日更新」也**只数这批**：以前它数的是**全部未归档任务**，于是那个「其中」
是假的 —— 卡上 2 条、副标里那个 1 却可能是某条逾期的，点进去当然找不到它。e2e 用
「**N ≤ 卡上的数字**」把这个不变量钉住（子集不会比全集大）。
（以前还配一条「进行中 chip ≥ 待办 chip」钉那个合并口径 —— 随「待办」一起撤了。）

**优先级分布**：五行 = 四档优先级，每行都是一个入口（点它去任务页按该档筛出）。标签写
**名字 + 编号**（`紧急(P0)`，`shared.ts` 的 `urgencyText()`，2026-09-20 改）—— 只写「紧急」的话
用户得自己记住它对应列表里哪个 `Px`，而列表上那枚徽标偏巧只写 `Px`，两边各说各话。

两处**刻意不带编号**，理由不同：
- `URGENCY_LABEL` 本身不动 —— 它会被拼进**活动记录的文本**（`紧急 → 关注`，见 `taskForm`），
  那句话是写进历史的**数据**，读起来该像人话，不该塞进编号；
- 带彩色徽标的地方（编辑面板那排按钮）不用它 —— 徽标本身就是编号，再写一遍就成了
  `P0 紧急(P0)`。

规矩一句话：**有徽标 → 徽标 + 名字；没有徽标 → 名字里带编号**。任务页那个筛选下拉也走同一个
函数，于是「点总览某一档 → 任务页顶部下拉」两处文案逐字相同（e2e 直接拿后者去比前者）。
`.ubar .un` 的宽度是**一行的宽度**（56px）而不是两个字的宽度：30px 时括号里的编号会被挤到色条上。

### 4.2 任务（甘特时间轴）

**需求**：看清任务的**时间跨度**，并就地增删改。（**进度不在条上** —— 它在行首那枚进度饼、
悬停小卡与条内那枚白字进度里，理由见下面「条 = 标准甘特」那条。）

- 表格的每一行是一条任务，行内画甘特条：**色条 = 创建日 → 截止日，整块单色 = 状态**
  （2026-09-21 起：不再填充进度、也不按档位切段 —— 见「条 = 标准甘特」）。
- ⚠️ **工具行那排档位（全部 / 昨天 / 今天 / 上周 / 本周 / 上月 / 本月）2026-09-21 撤了**
  （用户：「我突然感觉这些没有用，如果有反而不容易理解，是不是可以直接删除」）。它 2026-09-20
  的用途是「点一下，除了筛出这段时间**动过**的任务，还把**这一段范围内的进度在条上突出来**」
  （原话见下面「甘特窗口」那段）；而条在那之后就改回了**标准甘特**（整块实色、不画进度、
  不按档位切段）—— 这个用途没有承载物了。剩下的那件事（只列这段时间**动过**的任务）读起来
  像「看哪一段时间」，实际会让任务成批消失，于是变成「我看到的本周」与「我库里有的」两套口径。
  **想看「最近动过的」有更直白的地方**：总览的**近期活动**卡片本来就是按最新活动聚合的
  （前 50 个任务，见 §4.1）；任务页要收窄范围，状态筛选 / 搜索 / 排序 / 分页就够了。
  撤掉之后这一页**没有会自己变的视图状态**：打开就是「这个分区全部未归档任务」。
  —— 顺带记一处**文案审计**（2026-09-21）：页头那句说明原来写「档位从今天到全年」（关于面板里），
  而**从来没有「全年」这一档**，与按钮列对不上；页头说明也写过一串操作手势（「单击选中 ·
  双击打开抽屉 · 右键处置 · 批量迁入在任务管理页」）—— 既没说清这一页是什么，还把别页的入口
  写进来了。现在页头说明只回答「这里能看到什么」，操作手势交给悬浮提示。

**甘特窗口：固定 32 天 + 拖动平移**（2026-09-20 用户提的大改。原话：「按昨天/今天/上周…呈现的
效果都不好…按创建时间和截止时间创建进度条…点击快捷时间按钮时，除了原有的筛选任务外，重点突出
这个时间范围内的当前任务的进度…32 天显示最美观，其他只需要拖动即可」。⚠️ 其中「点快捷时间按钮
突出这一段范围内的进度」那一半，随那排档位在 2026-09-21 一起撤了）：

- **窗口固定 32 天**（`tasks.ts` 的 `GANTT_DAYS`），不按任务跨度撑开。
  默认窗口 = **前 16 天 + 今天 + 后 15 天**（`ganttAnchor = TODAY - 16`）：32 天没法在「今天」
  两侧各放 16 天（那要 33 天），取整必然偏一天，偏给**过去** —— 已经发生的事实要一直看得见。
  这个 32 后续可能搬进设置做成可配，现在不体现。
- **看别的时间段靠拖动**：在表格上按住横向拖动 = 平移窗口，**按天吸附**（拖过半格才挪一天）。
  监听挂在 `.tt-box` 上而**不是** `.tt-scroll` —— 后者每次 render 都被换掉。拖动 5px 以内算
  点击（否则「选中一行」会变成「窗口挪了一格」），拖过之后的那次 click 在捕获阶段被吃掉。
- **拖远了有出口**：今天不在窗口里时，表头那句区间变成强调色的「↺ 回到今天」，点它复位。
  没有出口的话，昨天那批数据可能在屏幕外好几星期。
- **条上只回答时间**：行里那条日期带（`.tt-band`）、条上的范围分段（`.seg-in` / `.seg-out`）、
  表头那几格高亮（`.tt-date.in-range`）都撤了 —— 一条上同时压「进度填充 + 范围内外 + 状态底色」
  三种说法时，最该看清的东西反而被盖住，用户的原话是「**进度和持续时间混合，导致甘特图本身
  可能失去价值**」。最后撤的是表头高亮那几格：它当时是「档位在哪一段」唯一的出口，档位没了，
  它也就没有要说的东西了。
- **条 = 时间 + 状态 + 进度**（三件事各占一个通道）：**长度** = 时间跨度（创建日 → 截止日，
  见下一条）；**色相** = 状态（进行中 / 已完成 / 逾期 —— 「待办」那档同日删掉，蓝也随色相
  体系撤了）；**填充**（`--fill`，条内左段）= **进度**。
  ⚠️ 这一条**翻过两次面**，两次都是用户定的，理由都留着：
  · 2026-09-21 早些时候：**取消进度填充**（原话「进度和持续时间混合，会导致甘特图失去
    价值」），改成「标准甘特」—— 位置（时间）与长度（进度）是两个量，叠在一条上读者分不清；
  · 2026-09-21 晚些：**又请了回来**（原话「不同进度的感觉差异不大」）—— 因为条上除了
    「哪段时间、什么状态」之外**没有第三个通道**说进度，68% 与 4% 的条长得一模一样。
    这次的做法是**只填长度、不叠别的**：底（未完成那一段）用同色系很浅的底，填充段是
    饱满的**状态色纯色**。位置与色相把前两件事说完了，填充再回答「走到哪了」，三者不抢戏。
  ⚠️ 同一次还修了一处：**逾期延长段改成半透明**（`.seg-late`）—— 它是实色、又在填充层之上，
  截止日在窗口左侧的逾期条（整条都是延长段）会把填充整个盖住，于是那些条看不出进度、
  和别的条长得不一样。**进度数字**仍在条内 + 行首那枚**进度饼**
  （`progressDot()`）+ 悬停小卡 + 条够宽时的条内白字进度。⚠️ 条上**只写 `N%`、不写标题**
  （2026-09-21 用户提的「没必要再显示任务名称」）：标题在左边任务列里已经占了一整行，条上再写
  一遍是同一句话说两遍，而条的宽度本来就紧张。阈值也随之从 78px 降到 **40px** —— 文字只剩
  三四个字符，太高的门槛会让大半条上是空的。
  另外它**贴条右端**（`right: 7px`）：它在**未完成那一段**上（进度 100% 时才落进填充段），
  而底色会在这两种之间切换 —— 所以描边要给足：白字四向各一道细描边，压在浅底上是
  「白字带黑边」、压在饱满的状态色上就是普通的白字。
- **逾期：条的右端渲染延长到今天**（`.seg-late` 警示色 + 原结束日处一道 `.late-mark` 竖记）。
  **数据里的结束日一个字节都不动** —— 那是事实，这是解释（与 §4.10 那条「完成日 + N」同源）。
  不延长的话，逾期与「刚好今天截止」在图上长得一模一样。
- **跨页请求进来时，窗口对准这批任务**（`focusAnchor`）。2026-09-20 用户报的是另一件事（选
  「上月」时窗口停在「上月 30 ～ 本月 21」，而任务集中在上月末到本月初 —— 窗口和任务错位，
  看着就是「点了像没反应」），但结论是通用的：**窗口要跟着任务走**，不是跟着日历走。规则：
  以这批任务**条形的跨度**（最早创建日 → 最晚截止日）**居中**，32 天装不下也照居中；一条任务
  都没有 → 回到默认的「今天前 16 天」。只在**请求进来的那一帧**自动挪，之后一律尊重用户的拖动
  （首页也不挪了：默认窗口 `TODAY - 16` 就是最中性的起点）。
- **条的时间基准：创建日 → 截止日**（原来用「开始 → 截止」）。创建日才是这条任务**进入视野**
  的那天，开始日只是它被排上日程的日子；「我手上这摊活儿从哪天起、到哪天必须交」是这一页要
  回答的问题。悬停小卡同步成「创建 X → 截止 Y」。⚠️ 由此**开始日不再影响条形**（字段仍在
  表单与数据里）—— 这是有意的取舍，不是漏。
- **两端跨出窗口时贴边 + 打截断标记**（`.tt-bar .cut`），整条都在窗口外时**两侧都打**：
  只打一侧会被读成「它从窗口外伸进来、在这里就结束了」。
  ⚠️ **右端那一夹 2026-10-16 才补上**（`renderRow` 的 `outsideRight = createdDay > winEnd`）：
  此前只夹了左端（`Math.max(createdDay, win.start)`），于是**创建日晚于窗口末日**的条
  `left` 会算到 `.tt-track` 的宽度（`win.days * colW`）**之外** —— 日期格线与周末带是铺在
  轨道上的重复渐变、轨道又不裁切，那条就飘在一片**没有日期格背景**的空地上，还顺带把表格
  撑出横向溢出。用户报的原话是「任务甘特图容易出现进度条与背景范围不同步…即有的任务中
  左右滚动时，发现甘特图进度条出现的地方，背景区域无对应的日期表格背景」——「左右滚动」
  正是因为那批条被画到了表格右边：得滚动才看得见它们（证伪跑实测 12 条 `bulk-*` 越界
  `.tt-track` 右缘 54px）。现在两端对称：整条在窗口外 → 贴最近的那一边 + 两侧截断记号。
  同一次还收了两处「记号越界」（同一类毛病）：**整条在窗口右侧之外时不画逾期延长段**
  （真实起止整段在窗口外，那段延长会盖满被夹到末列的桩），**竖记 `.late-mark` 只在落点
  确实落在条的时间范围内时才画**（真实结束日算到条外时，画出来是一条飘在条外的灰线）。
- **视觉语言（2026-09-21 配色整理；同日条改成标准甘特）**：一张表上最该跳出来的是**条**和
  **今天**，坐标系（格线 / 表头 / 周末 / 月首）一律收轻。全部走 `tokens.css` 的变量，
  深浅两个主题同一套规则：
  · **条 = 标准甘特条**（9px 圆角，与行首那枚圆形进度饼同族）：颜色只编码状态，**面是
    状态色纯色**（`--bar-state` ← `--doing` / `--done` / `--danger`），里面填的那一段
    （`::before`，宽度 = `--fill`）= 进度。
    ⚠️ **渐变试过、撤了**（2026-09-21）：三段色标（浅 → 本色 → 深）在 100% 填充的长条上会
    露出**两个折点**，读起来像条被切成几截 —— 用户的原话是「**渐变分层**」，随后要求
    「放弃渐变色」。前两版也都不成立：斜向 / 水平会被**长度稀释**（几百 px 走完那点差值），
    垂直只有 **19px** 的跨度、根本铺不开。**纯色才是稳的**：色相直接等于状态，进度交给填充
    长度 —— 顺带把条与徽标、进度饼、优先级色阶重新对回了同一份色值。
    **「进行中」的基色**同日从 `#c07f2d` 换成 `#d98b1f`（用户嫌原来那个「不好看」——
    偏黄、饱和度低，确实像芥末；深色主题同步 `#dfa24e` → `#e8ab45`）。它同时是优先级 P1
    的停靠点，换掉之后「红 → 亮橙 → 黄绿 → 绿」那条色阶跟着变亮，过渡反而更顺。
    条内白字的描边保留 —— 它会压在两种底上（饱满的状态色 / 很浅的未完成底）。
    ⚠️ **进度数字要抬到逾期延长段之上**（`.tt-bar b` 的 `z-index: 3`）：延长段（`.seg-late`，
    `z-index: 2`）从结束日一直铺到今天、正好压在条的右端，而进度数字也贴右端 —— 不抬起来，
    **逾期条上的进度会被整个盖掉**（2026-09-21 用户报的「逾期的甘特图上没有进度显示」）。
    **进度有两种画法**（同日各上过一次，见 §4.2 那条两次翻面的说明）：底 + 填充段
    （`::before`，宽度 = `--fill`），以及行首那枚进度饼、悬停小卡与条内那道白字进度；
  · **周末**：表头那格上底色，行里用「7 天一个周期、宽 2 天（周六 + 周日）」的 repeating 渐变
    + `background-position: var(--tt-wk)` 对齐到窗口里的第一个周六 —— 每格一个节点那种画法
    在 32 天 × 28 行下要多出近千个只用来铺色的 div；
  · **月首**：表头那格把「周几」换成「几月」并加一条左分隔线，行里同一列一条 `.tt-moline`
    （两处指同一根线）；
  · **今天**：表头一格（底色 + 底线）+ 行里一条 2px 竖线（`.tt-today`），
    **只在今天确实落在窗口里时才画**（2026-09-21 用户提的）—— 拖到别的月份之后，一条画在
    屏幕外或被夹在边上的线只会误导人，那时的出口是表头那句「↺ 回到今天」；今天贴到窗口两端时
    那个「今天」小签贴边对齐（居中的话会伸进任务列、或被吸附列盖掉半截，越过右边界还会把表撑宽）；
  · **档位那三处高亮都撤了**（行里的日期带 `.tt-band`、条上的分段 `.seg-in` / `.seg-out`、
    表头那几格 `.tt-date.in-range`）：一条上同时压「进度 / 范围内外 / 状态」三种说法时，最该
    看清的东西反而被盖住。表头那几格是最后撤的 —— 它当时是「档位在哪一段」唯一的出口，
    而那排档位 2026-09-21 一起撤了（见 §4.2）。
- **别再退回「把窗口撑开到装下所有任务」那种做法**（`fitWindow` 曾经无上限）：它会让六个档位
  算出同一个窗口、点哪个都像没反应，而且跨月数据会把甘特挤成几百根 15px 的细线。
  也**别再按档位把窗口缩到 1 天 / 7 天**：那是「今天 = 一根线」的来历。
- **行首那枚「进度饼」**（2026-09-20 改，`tasks.ts` 的 `progressDot()`）：一个圆环 + 按进度从
  12 点顺时针长出的扇形（CSS `conic-gradient`，百分比由 TS 写进 `--pct`，样式表里不出现 JS 拼的
  样式串）。它取代了原来那枚 8px 的状态圆点 —— 圆点**只有颜色一个通道**，而它表达的是状态，
  一个裸色点挂在任务名前面，谁都会以为那是进度（当天就这么被问过一次）。
  现在**形状说进度、颜色说状态**，两个通道各管一件事：颜色仍走 `statusVar()`
  （逾期红 / 进行中橙 / 已完成绿；历史上的「待办」与进行中同色）。两端各给一个更好认的记号：
  **进行中 + 0% → ▶**（还没开始，可以动手）、**100% → ✓**（实心圆 + 白勾）——
  勾必须固定白色，跟着 `currentColor` 走会和底同色，等于没画。
  ⚠️ 两个通道**不再各说各话**（2026-10-16）：进度不满 100% 就不可能停在「已完成」，满 100% 就
  一定是「已完成」（口径见 §4.10）。于是「红色 ✓」——颜色说逾期、形状说做完——这种组合不会再
  出现；它骗过用户一次，也让一轮排查绕了远路（任务因此收不进归档）。**显示口径本身没动**：
  仍是形状说进度、颜色说状态，两端那两枚记号照旧。
  `data-pct` / `data-mark` 把「画出来的那个值」在 DOM 上留一份可读的：进度现在是图形，
  读屏、调试、以及「与悬停提示对不对得上」这类断言都得有个据。
  任务列宽度因此 248 → **256**（`.tt-label` 与 `LABEL_W` 必须同步，差 8px 表头那条「今天」
  竖线就会偏）。管理页那条进度列的写法则不动：它是「条 + 数字」，本来就看得见数值。
- **任务列两行的分工**（2026-09-20 改，`tasks.ts` 的 `renderRow`）：**第一行只放标题**，
  第二行是「标签 + 截止 + 优先级」。以前标题与截止共用第一行、优先级徽标另占右端一列，
  标题只剩半列宽（约 120–150px），稍长一点就成了省略号 —— 列表里看不出这是哪条任务，
  看不出等于没有。截止与优先级都是**短记号**，让它们去分标题的宽度是本末倒置；并到标签
  那一排之后，标题吃满整列（207px）。**挤不开时的取舍写在 CSS 注释里**：先缩标签（标签缩掉
  一截还看得出是标签），截止与优先级 `flex: none` 永不缺席 —— 它们各占一个固定位置，
  被整条裁掉就再也找不回来。截止 `margin-left:auto` 靠右（标签长短不固定，不靠右这一排会
  跟着晃），优先级仍钉在**任务列右端**：与行首那枚进度饼一左一右，颜色与位置两个通道不打架。
- **`seg` 是无状态控件**（`shell/seg.ts` 顶部）：点了之后**调用方要调 `setValue`**，否则高亮
  停在原处 —— 任务页那排档位当年就漏过这一步，「点了像没反应」其实有两层理由（窗口被撑开 +
  高亮的是上一个）。现在 seg 只剩总览的焦点时间轴在用，那一处不用补：它整页重建，seg 每次
  都是新造的。
- 工具行：搜索、状态与优先级筛选、**排序**、每页条数（那排档位 2026-09-21 撤了，见上）。
  **归档筛选在管理页那一页**（只有它显示已归档任务），这里没有。
- **默认排序「按优先级」**（2026-09-21 用户定的，原来默认「按截止日期」）。优先级相同的
  再按截止日期排（`comparators.urgency`）——「今天必须交的 P1」不会被埋在一堆 P0 后面。
- **下拉是「点了要切得动」的反面教材**（2026-09-21 用户报的：排序下拉切不到别的选项上）。
  `dropdown` 与 `seg` 一样是**无状态**控件，而它原来只有 `setValue` 会改显示 —— 菜单行
  自己点了**不同步**：值换了、表也重排了，按钮上却还写着旧的那一项、勾也还在旧项上，
  看着就是「切不过去」。现在**菜单行自己同步显示**（`menu.ts` 的 row 回调里先 `apply`
  再 `onPick`），`setValue` 留给「**别处**改了值」的场合（跨页请求复位优先级筛选那类）。
  e2e 两条：默认是「按优先级」、切两次之后按钮文案与勾选项都跟着换。
- **「今日到期」是一枚独立 chip，不是状态那排的第六项**（2026-09-20）：它筛的是**截止日**，
  与状态是两个维度。两种筛选**互斥** —— 点亮它就把状态复位成「全部」，点状态那排就把它关掉；
  它开着时状态那排**一个都不高亮**（那时列表是「今天到期」那批，不是「全部」，两个 chip 同时
  亮着读起来像这次筛选有两层，说不清）。它是总览那张「今日到期」卡的落点：没有它，点进来只
  看到 1 条，页面上没有任何地方说得清为什么，也没有出口（它的开关就是出口，再点一下退回全部）。
  判据与卡片同源，见 §4.1 的 `isDueToday`。
- 页头只有**一个**动作：`＋ 新建任务`（走与编辑同一套的完整表单）。批量入口不在这儿 ——
  见下面「数据迁入」。

**数据迁入**（`tasks.ts` 的 `openImportDialog`，入口在任务管理页，与「导出 ▾」并排）：
一次把一批任务连同**它们的活动时间线**建出来 —— 也是迁移旧数据的唯一通道。四件事是刻意的：

1. **只有选文件，没有粘贴框**。转换工具的产物本来就是**一个文件**，中间再绕一趟
   「打开 → 全选 → 复制 → 切窗口 → 粘贴」只是多几步；而留着粘贴框会让这一屏同时服务
   「迁移」和「随手录十条」两件事，名字重新变模糊。实现用 `<input type="file">` +
   `File.text()`：不用 `plugin-dialog` / `plugin-fs`，浏览器里也能跑，e2e 能用
   `setInputFiles` 真测。
2. **解析出几条、哪几行认不出、哪几条与库里同名**，都要在点「导入」之前看见。
   `markdown.ts` 的 `parseTasksDetailed` 就是为这件事拆出来的：解析结果除了草稿，还带
   `skipped`（行号 + 原文，空行不算）。跟迁移工具那套「先对账」是同一条规矩 ——
   **少掉的东西必须能看见**。
3. **重复只提示、不替用户决定**：判重规则只有一条 —— 标题在**当前分区**里已存在。
   同名不一定同一条，所以给「跳过重复」与「全部导入」两个按钮；旧版里改过标题的认不出，
   界面上也写了这句。默认动作是「跳过重复」—— 造出重复数据的清理成本比少导几条高。
4. **只告知当前分区**（`导入到「演示空间」`），不猜也不拦 —— 导到哪个由用户自己切好再进来。
   旧版本来就是按分区导出的，所以一个文件对应一个目标分区。

这一屏**只留三样**：导到哪儿 / 选文件 / 解析结果。不在这儿讲写法 —— 语法写在
[`resources/skill/tadado-activity-import/SKILL.md`](resources/skill/tadado-activity-import/SKILL.md)，
而文件是工具产出的，用户不需要再看一遍。

> 「批量新建任务」这个名字、以及它先后挂在工具行与页头的那两段历史，见 §8.3。
- **换筛选要回第一页**：消费跨页请求时重置页码。不重置的话，从总览点「逾期 38」进来会停在
  上一轮翻到的第 2 页，看到的只是后半截。
- 列宽与格线由 CSS 变量 `--tt-col` 统一（TS 一处算、表头与 `.tt-track` 的格线周期都取它），
  避免各列自己算。

### 4.3 任务图谱

**需求**：回答「这条任务和什么有关」，而不是再给一张列表。

- 布局是**确定性**的力导向（同一份数据每次画出同一张图，否则「刚才那个节点去哪了」没法回答）。
- ⚠️ **取数含归档**（2026-09-21 用户报的「图谱只显示进行中，已完成的都不会显示」）：
  默认「完成后归档＝立即」（§4.10）之下，做完的任务**当场**就离开 `activeTasks()` —— 图谱上
  于是永远只剩「进行中」，那张图看起来像坏了。**归档只该影响任务页**（那页的定位是「手上的
  活儿」，`activeTasks` 就是为它写的）；其余模块都是**看全貌**的：活动分析与管理页本来就含
  归档，图谱跟上（总览那张「已归档」卡也是同一个意思）。取数改成 `partitionTasks()`。
- 节点三类：**分区**（根）→ **标签** → **任务**；连线表示归属与关联。
- 悬停高亮相邻关系，双击任务直达维护抽屉。
- **点画布空白收起右上角的详情卡**（2026-10-16 加）：节点高亮一起清掉，卡片退回
  「选择一个节点」。这一手是**本地**的（`clearDetail()` 挂在 `.gwrap` 的 click 上），
  没有放进 `shell/panels.ts` —— 它不是从右侧滑出的抽屉、也不参与「同一时刻最多一块」
  那条互斥，只是跟着**选中**走的一块浮层，塞进面板层只会让那一层多认识一个概念。
  节点自己的 click 已经 `stopPropagation`（选中即展示，不能同一次点击里又被清掉），
  stage 上那几块浮动件（`.gdetail` / `.gstats` / `.gpart` / `.gzoom`）逐个豁免 ——
  尤其 `.gzoom`，它就在卡片正下方，是最容易被误伤的一个。
- 舞台按可用空间铺开，吃掉主区剩余高度。
- 图例用语与全应用统一：「分区」不叫「根节点」（后者是图论术语，对用户没有信息量）。

### 4.4 活动分析

**需求**：回顾「时间花在哪」，并按标签切分。

- 上半是**整年热力图**（12 个月块，颜色深浅 = 当天活动数），可换配色方案（`data-scheme`）。
- 范围条横跨整页：快捷范围（本周 / 本月 / 本年 / 指定）与年份切换；**选「指定范围」才出现日期框**。
- 点热力图上的一天 = 报告切到那一天。
- 下半是**分标签报告**：标签列表只留当前范围**有活动**的标签，不解释被隐藏的；`◀ #标签 ▶` 翻页
  不会翻到没活动的标签。条数文本排在**标签切换组外面**（它数的是当前标签在当前范围的条数，
  挂在标题旁会被读成「整份报告共几条」）。
- **「全选」是一枚开关，不是两个单向按钮**（2026-09-20 定稿）：默认全选 → 用户可以逐个调整
  勾选 → **再点一次 = 取消全选**。原来那枚「清空」撤掉了。

  起因是一句反馈：「清空好像没用，全选再点一次就取消全选了、和清空一样」。实测（一次性探针，
  依次点：初始 → 全选 → 全选 → 清空 → 全选）：当时的「全选」**只 `add`、从不 `delete`**，
  并不取消勾选 —— 但它在默认状态下**永远是空操作**，因为默认勾选集就等于「当前范围内有活动的
  标签」（也就是左侧列表里那几个）。所以真正的病根不是「它和清空重复」，而是**那个按钮在默认
  状态下没有事可做**：一个点了没反应的按钮，只能引出两种结论 ——「它坏了」或者「它和旁边那个
  是一回事」，而这两句出现在同一条反馈里。

  开关的形状把病根去掉了：**两边都有活儿干，点下去一定有变化**。代价写清楚：清掉「部分勾选」
  要点两下（先全选、再取消全选）。状态画在颜色上（`.on` = 强调色）：全勾着时按钮是亮的 ——
  那一亮就是「现在是全选状态」的说明，不必再拿一个文字标签去讲。只有列表里一个标签都没有
  （这个范围里没有标签有活动）时才置灰 —— 那时两边都没事可做。
  e2e 照开关的语义验三段：默认全选（亮）→ 点一次全清（**报告跟着空**，不是「看着没勾、
  其实还在筛」）→ 再点一次全回来。（中间试过「两个按钮 + 没活儿干时置灰」那一版，最后按
  这个方向收口。）
- 导出与范围同行，独立按钮置最右。

### 4.5 任务管理

**需求**：批量处置与标签维护。这是**唯一显示已归档任务**的视图。

- 表格可多选，批量动作：**标记完成 / 归档 / 取消归档 / 删除**（删除走二次确认）。表头有
  整页勾选框。（这里曾写成「改状态 / 延后 / 中止 / 删除」—— 延后与中止在桌面端**根本不存在**，
  是从 v0.x 的特性清单里抄过来的。）
- **「归档」那一列是只读的**（与「状态」「标签」两列同性质）：只摆标 —— `已归档` / `—`
  （破折号那格悬停补一句「未归档」）。**曾经在这里放过一枚单条「归档 / 取消归档」按钮，撤了**
  （2026-09-21 用户定的：「只显示归档标签即可，类似于『标签』或者『状态』」）—— 那一列是**看**
  的，动手的地方是页头那枚**按筛选**的按钮，以及**勾选之后**的批量栏；单条处置就走「勾一行 +
  批量栏」，与这一页其他批量动作同一条路。
  **已有数据交给用户自己处置**：不替老库做决定（见 §4.10 的一次性冻结），收哪几条由这两处说了算。
- **页头「导出」之后是一枚批量归档按钮**（2026-09-21 用户提的「在导出之后增加一个归档按钮
  即可」）：「批量筛选」这件事表格上那两个筛选（状态 × 归档）已经做完了，这一枚只负责**执行**
  —— 按**当前筛选（跨页）**归档 / 取消归档。所以它**不需要**先勾选，标签里也**不写条数**
  （条数是筛选的产物，写进按钮等于把同一件事说两遍）。动作与标签都跟着**归档档位**走，
  判据是「**不全是已归档**」：只有停在「已归档」那一枚时是「取消归档」，其余（「未归档」，
  以及点状态那几枚时的 `"all"`）都是「归档」—— 一次只可能做对该做的那件事。
  ⚠️ 写成 `archiveFilter === "active"` 是错的：点状态那几枚 chip 会把归档维打回 `"all"`，
  于是**默认进这一页按钮上就挂着「取消归档」**，与眼前这批对不上。
  走二次确认（标题里写明条数），恢复的那条路同样 `keepActive`；筛出 0 条时给一句提示，不静默
  无反应（那正是这一页最容易被当成坏了的样子）。
  （中间试过一版「常驻批量条 + 按钮上写条数」，撤了：「勾选」与「按筛选」是两种输入，
  挤在同一条里只会让人以为必须先勾选。）
- **筛选是**一条**单选**（2026-09-21 用户定的）：`全部状态 / 逾期 / 进行中 / 已完成 /
  未归档 / 已归档` —— **每一枚 = 一个视图**，永远只亮一枚，默认「全部状态」。以前是「状态 ×
  归档」两组 chip，各自单选却挨在一起 —— 看起来就是一条单选行里亮了两枚，于是被当成坏了
  （用户那句「默认怎么不是全部状态」就是从这儿来的）。**「待办」那枚同一天撤了** ——
  状态本身删了（见 §4.1），它和「进行中」本来就是一回事。
  合单的规则：状态那几枚**不筛归档**（两侧都算，哪条是哪条看「归档」那一列），归档那两枚
  **不筛状态**。内部仍保留 `archiveFilter` 的第三态 `"all"` —— 它正是状态那几枚在用的。
  **代价说清楚**：组合筛选表达不出来了（「已完成 × 已归档」这种问法没有入口）。换来的是
  这一页的筛选一眼就懂。
- 标签管理：选中标签可重命名，**改为已有名称即为合并**。
- 导出跟着**当前筛选**走（表格上摆着状态与归档两个筛选，导出的就该是眼前这批）。
- **「数据迁入」与「导出 ▾」并排**：一进一出。它导的**不是**上面那种给人看的清单（那种本来就
  回不来），而是**任务行写法** —— 也是唯一能把活动历史一起带进来的通道（见 §4.2）。
- 「数据迁入」的对账因此有**三块**：认不出的行、与库里同名的、**起点被按截止日修正的**
  （2026-10-16 加）。最后这块是这页里唯一**替用户动过数据**的一处，所以它必须像另两块一样
  在点「导入」之前逐条列出来（`.batch-adj`，最多 5 条 + 「…另有 N 条」，与另两块同一套排布；
  导入后的回执也会提一句）：md 里只写截止日，**起点与创建日是解析器推断的**（最早一条活动的
  日期，见 §4.8），推断出来的起点晚于截止日时，两者一并落到截止日 —— 截止是源里写着的、
  最早那条活动也是源里写着的，**两个事实一个都不动**。反过来的做法（把截止顺手推后）等于
  修改用户写下的截止时间，没人授权。手写的行也会碰到这一条：它们没有活动行，起点默认是
  「导入日」，而**手写的过期任务**（截止写在过去）正是「起点晚于截止日」的典型。

### 4.6 维护抽屉与定义表单

**需求**：一条任务的全部信息与它的历史，一处看完。

抽屉分两段：上面「**任务定义**」（这条任务**是什么**），下面「**活动时间线**」（它**经历过什么**）。
以前这两串字段与记录平铺在一起，中间没有任何边界。

- 改状态、改进度、改优先级都会**写一条活动记录** —— 变更留痕是这套数据模型的核心，
  否则「本周完成」这类统计只能靠状态字段倒推，改一次状态就丢一段历史。
- ~~**写一条进展 = 这条活儿动起来了**~~（2026-09-21 加，**同日晚些撤掉**）：它当年做的是
  「写第一条记录 → 把待办推到进行中」，理由与实现都成立；但那之后**「待办」这一档本身删了**
  （见 §4.1），新建任务就是「进行中」—— **没有可推的起点**，那段逻辑随之撤掉。留下的只有
  活动记录里那些 `from: "todo"` 的历史，时间线照旧显示「待办 → 进行中」。
- 抽屉底部**没有删除与保存按钮**：改动即时生效（删除在右键菜单与表单里），
  没有页脚就没有「我改了但没保存」的状态。
- **换一条任务 = 先滑出完、再滑入新的**（2026-10-16 用户提的：「查看第一个任务时点
  第二个，先关闭第一个的滑窗，然后才是第二个的弹窗，**而不是原地更换内容**」）。原来
  就是在同一条抽屉 DOM 上原地换内容：动画走的是同一个元素，前一条的内容跟着一起滑进
  滑出，看上去只是抖了一下，读不出「换了一条」。判据在 `openTask`：抽屉**开着**且看的
  是另一条时，先摘 `.open`、等 `transitionend`（`PANEL_LEAVE_MS` 兜底 —— 减少动效、
  元素被移走、上一次过渡没走完就被改回原值这三种情况下过渡事件都不会来）再 `paint`
  新任务并滑回来。三条约束：
  ① 滑出期间 `openedPanel()` **仍然是 `"task"`**。这样「点面板外」「开设置抽屉」
  「切页」「Esc」走的都还是既有那几条路，而它们最后都会落到 `closeTask`，由 `closeTask`
  把这一手撤掉；反过来，滑出时就把面板标成「已关」的话，这几条路全都拦不住这次滑入
  （设置抽屉之上会再滑出一块任务抽屉，互斥就破了）。
  ② 待执行的滑入**必须可取消**：连点第三条任务只换目标、动画不重头再来（每点一下都
  重新滑一次，看着像在原地抽搐）；`closeTask` 里的取消要放在最早一行 —— 那一刻 `.open`
  已经摘了，正是最容易被误判成「没开」而提前 return 的时候。
  ③ 这段状态机只能放在**页面层**：A 与 B 是同一个 `PanelId = "task"`，`panels.ts`
  分辨不出「换了一条」。设置 ⇄ 任务之间的互斥没有跟着改（两块仍然是同时动），
  本次只要求任务 A→B 的先关后开。
- 记录可编辑、可删除，编辑过的标出「已编辑」。
- 底部是 **Markdown 源**（可折叠）：敲 md 就地渲染成标题 / 标签 / 截止 / 进度，
  点「按 md 更新任务」才写回。**这条写回路径不采纳状态**（见 §4.8）。
- 新建走页头的「＋ 新建任务」，打开的是**和编辑同一套表单**（`taskForm.ts`）。
- **新建时「结束」默认今天 + 当前时刻**（`draftTask()`，2026-09-21 用户提的：「时分默认是
  当前时间，目前是空，需要手动选择」）。两件事一起修：时刻原本是空的（点完「今天」还得再挑
  一次时分，而绝大多数新建就是「从现在起」）；日期框本来就显示着今天，模型里的 `due` 却是
  null —— 字段**看着填好了**、点「创建」却说「还差：结束时间」，显示与模型两个口径。
  现在 `due` / `at` / `end` 一次给齐。「结束时间是必填」这条规矩仍然成立：点「清除」之后
  `due` 就是 null，创建时照样拦下来（e2e 两条分别钉住：默认值、以及清掉之后被拦）。
- **起止区间不许倒挂：结束不得早于开始**（2026-10-16 加，**同日合法** —— 开始的当天就是
  截止日是最常见的排法之一，种子里的「健身房训练」「晨会记录」「晨跑 5 公里」都是
  `start === end`）。判据只写一处：`data/time.ts` 的 `isRangeInverted(startDay, endDay)`
  = `endDay < startDay`（写成 `<=` 就把同日错杀了）。
  **拦截点也只有一处：日期控件**（`shell/dateTime.ts` 的 `bounds` / `peerLabel`）。两栏互为
  对方的边界 —— 原生 `min` / `max` 把越界的那一天在取色盘里灰掉，提交前再判一次兜住手输与
  粘贴；越界时**不触发 `onChange`**（模型一个字节都不动）、显示退回原值，并 toast 说清是被
  哪一端卡住的（「开始不能晚于结束（10-16）」）。**为什么不在各页面自己判**：抽屉是
  **即时落库**的（`onEdit: () => dataChanged()`），拦在出口之前才不需要「先写坏、再回滚」——
  那条路上任何一个岔子都会在库里留下一个倒挂区间；而且新建对话框与抽屉共用这一套控件，
  两处表现天然一致（判据要是抄两份，迟早一边漏）。
  两处涟漪一起处理：① 结束栏「清除截止」原本把 `end` 落回**写死的今天**，开始日排在将来时
  会当场倒挂 → 现在落回「今天与开始日里**较晚**的那个」（同时保住「清除后不再算逾期」这条
  既有承诺）；② **没有截止时 `end` 只是必填的占位日**（界面上不显示「未结束」），所以
  「开始」那一栏此时**不设上界** —— 拿一个看不见的占位日卡住开始，等于不让人给没有截止的
  任务排将来的起点；开始越过占位日时把它**一起带走**（模型里始终 `end >= start`）。
  **历史数据不处理**（2026-10-16 定的）：读入口不纠正、不写迁移，库里已有的倒挂区间照旧
  显示；但**只改时分不会被拦** —— 判据只在**日期真的被改动**时生效，否则那些老任务的时刻
  就永远改不动了。同一处顺带收掉一个老洞：「开始」栏用键盘删空原本会显示成「未设置」而
  模型还留着旧值（显示与模型两个口径），现在按「必填项不给清」退回原值 —— 与「不摆
  『清除』按钮」是同一条理由。

**进度就在活动时间线里，草稿式提交，而且一律手动维护**（2026-09-20 用户提的）：写一句进展、
把进度挪到哪，本来是同一次动作的两半 —— 以前一个在「任务定义」（滑杆 + 数字框）、一个在时间线
的输入框，用起来必然漏维护一处。现在：

- **控件搬到活动时间线**（`.tl-prog`，就在「记录一条进展」那一行上方），任务定义里不再重复
  一份（抽屉传 `taskForm({ withProgress: false })`）。控件抽成 `taskForm.ts` 的
  `progressControl()`：对话框用 `mode: "immediate"`（那边没有「发送」按钮，表单本来即时保存），
  抽屉用 `mode: "deferred"`。
- **左边那条就是滑杆本身**：带填充 = 一眼看出到哪了，拖一下就是改；右边数字框给精确值。
  不另摆一条只读进度条 —— 两条长得一样，摆在一起只是重复。
- **拖动不落库，按「发送」才保存**（同日修的两个 bug 之一：「进度条调整后，还没来得及编辑
  信息，已经被提交了」）。deferred 模式下拖 / 填只改**草稿**，控件把它写在脸上
  （`.prog-pending`「待保存 65% → 70%」）—— 不写出来，用户拖完就走，改动静默消失，那是同一
  类毛病的另一面。光改进度不写说明时不保存，给一句提示并聚焦输入框。
- **说明与进度落在同一条记录上**：提交时那句说明就是这条进度记录的 `text`（`commit(text)`）。
  另起一条 `log` 等于又把信息与进度拆开，那正是这次要修的毛病。
- **进度记录可以再编辑**（同日修的第二个 bug：带说明的进度记录长得像 log，却是
  `kind: "progress"`，当时没有编辑入口）。编辑能改说明**和值**，但值有上界：
  `progressEditBound()` = **后面（更新的）那条进度记录的 `to`**（它自己是最新则到 100）——
  时间线是**单调**的，把老记录改到超过后来的值，页面上就会出现「先 80、后 70」这种倒着走的
  进度，而两条都是「真的」，这条时间线就不再能读。改完把**后面那条的 `from` 跟着挪**
  （链条必须接得上）；改的是最新那条时，`task.progress` 一起更新。删掉最新那条进度记录时
  同理：任务的当前进度退回它的 `from`。
- **不做任何自动推导**：用户用了很多次之后明确定过「**进度手动维护是最好的设置**」——
  拖到哪、填多少就是多少。

**时间线的读法：正序（追加模式）**（2026-09-20 用户提的「倒序读起来别扭」）：最早的在上面，
最新的贴着下面那一行输入框 —— 这本台账是往后写的，读到最底下正好接着「记录一条进展」。

- **存储顺序不动**：`activities` 仍然**最新在前**（`data/markdown.ts` 里写明的那条约定，
  导出、统计、以及「后面那条记录」这个上界判定都依赖它），只在 `renderTimeline` 里反着铺。
- 因此**就地编辑不能再按 DOM 下标取那条记录**（渲染下标与数组下标刚好相反），卡片引用是
  直接传进去的。
- **就地编辑区是两行**：第一行整行给「说明」，第二行才是进度值与保存 / 取消。以前全挤在
  一行里，说明输入框只剩一百多像素（用户：「再次编辑时信息编辑区域太小了」）。

### 4.7 分页与跨页定位

- 三张表 + 一张活动流共用 `pager.ts` 与同一组档位（20 / 30 / 50 / 100）。档位**不进设置** ——
  它是「这次看多少条」，不是一项偏好。**默认值各自不同**：三张带日期列的表 20，总览近期活动
  **50**（`FEED_PAGE_SIZE = FEED_LIMIT`，2026-09-20；那张卡问的是「最近发生了什么」，20 行
  只够看半天）。
- **「每页几条」与「上限」是两件事**：总览近期活动另有 `FEED_LIMIT = 50`（只摆最近的 50 个
  任务，`renderFeed` 里在排序之后截断）。默认档位等于上限，所以平时**只有一页**；档位调到
  20/30 时是在**这 50 条以内**翻，翻不到第 50 条之外去 —— 「看更早的」归活动分析页。
- **单位与「报不报总数」都由调用方给**（`pager({ unit, showTotal })`，2026-09-20）：
  总览的近期活动是按**任务**聚合的 —— ① 用默认的单位「条」会和同一张卡里的「共 N 条」
  （某个任务的活动条数）串味，所以那张表传 `unit: "个任务"`；② 那句「共 88 个任务」会被
  读成「这一屏显示了 88 条」（而 88 是聚合后的任务总数），所以它再传 `showTotal: false`，
  只报范围「第 1–50 个任务」。**空表时两种写法都只写「共 0」/「暂无」**，不写「第 1–0」——
  那不是一句人话。**档位下拉只写数**（`50/页`）：单位由紧挨着的那句范围说明给，说两遍更挤。
- **只有一页时不摆翻页件**（2026-09-20 用户提的「分页样式繁琐」）：`◀ 1 / 1 ▶` 一个都按不动，
  却和真正要读的范围说明一样显眼。这一段同时收紧了间距（10 → 6）与档位下拉的外观 ——
  它是次级设置，在分页器里不该长得像主按钮。
- ⚠️ **下拉在下方装不下时要向上翻**（`menu.ts` 的 `openPanel` + `.menu.up`，2026-09-20 用户
  报的）：分页器贴在卡片底部，而这几张卡的正文是 `overflow: hidden`（撑满场景的硬要求），
  菜单默认向下展开会落在裁切线以下 —— **看得见一截、点不到任何一项**。判据只看视口：
  下方放不下整块菜单且上方更宽裕就翻上去。e2e 量两件事：菜单整块落在卡片**以内**、
  点一项真的生效（`分页器的档位下拉向上翻`）。
- 跨页请求走 `focus.ts`：**单向请求 + 单点消费**。「点总览的数字跳到任务页」这类动作只表达
  「要去看这条 / 这一批」，由任务页自己决定怎么落地（排序、筛选、选中、高亮）；
  反过来让总览去操作任务页的内部状态，两页就耦死了。
- **请求要把「能把目标挡住的东西」一起复位**：搜索词、页码（当年还有**档位** —— 它非「全部」
  时只列该范围里动过的任务，从总览点「逾期 40」进来只会看到本周动过的那几条；那排档位本身
  2026-09-21 撤了，所以现在只剩前两样）。数字都没算错，但看着就是对不上。
  控件是常驻的，别处改了值要 `setValue` 把显示跟上：seg 与下拉都是**无状态**控件，
  自己不知道数据被别处改过。

### 4.8 Markdown 写法（与 Py 版的主要分叉点）

一行一条任务，是这个应用与外界交换数据的格式：

```
- [ ] 标题 #标签 ⏰09-21 14:30 :: 40%
   - 09-20 08:15 记一条进展
```

| 写法 | 含义 |
|------|------|
| `[~]` / `[x]` | 状态：进行中 / 已完成 |
| `[ ]` | **也认**（老文件与别处导出的清单里都是它），读进来算「进行中」—— 「待办」那档 2026-09-21 删了。⚠️ 代价：往返会把 `[ ]` 写成 `[~]` |
| `#标签` | 标签，最多 3 个 |
| `⏰MM-DD [HH:MM]` | 截止；不写 = 无截止（年份按离今天最近的一年推算） |
| `:: 40%` | 进度 |
| 缩进 ≥2 格、`MM-DD HH:MM` 开头 | **活动行**，挂到上一条任务的时间线上 |
| 行尾 `+1w` 之类 | 旧版遗留的循环标记：字段已删，但解析时仍要**吃掉**它（否则会粘进标题） |

**与 Py 版的分叉**：那边方括号里装的是**优先级**（`[***]`）、日期写在 `<>` 里。两版写法**不通用**。

**活动行为什么存在**：活动分析导出的清单是「标签 → 任务 → 活动」三层，它是迁移旧数据时**唯一
带着活动历史**的载体 —— 不带这一条，导进来的任务就只有标题，几十上百条流水全丢。

**状态的一处取舍**（这条最容易被误读）：这套写法**是**带状态的 —— `taskToMarkdown` 会写出 `[x]`、
`parseTasks` 也会读它，**批量新建正是靠它带状态**。**不采纳**状态的只有抽屉那条写回路径
（`taskDrawer.ts` 的 `mdApply` 只取标题 / 标签 / 进度 / 截止，状态保持原值，toast 会说出来）。
改状态请在抽屉的表单里改，别指望改 md 那一行。

⚠️ 2026-10-16 补一句边界：**「不采纳」的只是那个复选框，不是进度的语义** —— 进度与状态是同一个
口径的两面（§4.10），所以 md 里写着 `:: 100%` 时，写回之后状态**会**跟着变成「已完成」，toast
照实说。**解析**那一侧做得更彻底：方括号与百分比打架时**复选框优先**（`[x] :: 40%` ⇒ 已完成 +
100%，`[~] x :: 100%` ⇒ 已完成），解析出来的形态与界面里的一致 —— 不然导进来那条会被下一次
自愈扫描当场翻掉，看着像「导入完又自己变回去」。

**不写回的还有一处：倒挂的截止日**（2026-10-16 加）。md 里的截止日早于这条任务的**开始日**时，
这条截止（`due` / `at` / `end`）**保持原值**，标题 / 标签 / 进度照旧写入，toast 把两个日期
分别说清楚。理由与「状态不采纳」同源：md 在这里本来就是**有选择地**被采纳的；反过来挪开始日
去迁就它，等于改掉一条用户写下的历史，比拒绝更糟。**迁入那条路的选择不一样** —— 那里没有
「原值」可言，起点又本来就是解析器推断的，所以是**按截止日修正起点并逐条列出来**（见 §4.5），
而不是把整条丢掉。

> 本文档的这一段曾经写反过（说「状态不写回 md，所以导出再导入会把已完成变成待办」），
> 与代码不符。真实情况如上。

### 4.9 导出

一处定义三种格式，两个用到导出的页面共用（`export.ts`）：

- **md 与 txt 内容一致**（同一段文本，只有扩展名不同，排版与 Py 版对齐），xlsx 是同一批数据的表格形态。
- 结构是三层：**标签分块 → 任务（有序）→ 该任务的活动（无序）**。管理页按**第一个标签**分块
  （一条任务只出现一次 —— 这是任务清单，同一条出现两遍会让人以为有两件事）；活动分析那边按标签
  查活动，天生会重复，所以那边去重。（「有序 / 无序」说的是 **md 的列表类型** —— `1.` 与 `-`，
  不是时间先后。）
- **活动行按时间升序**（2026-10-16 用户定的「活动信息导出中时间按照升序显示」）：同一个任务下的
  几条进展从早读到晚 —— 这是一份要存档、要转手的台账，倒序读起来是「先看到结果、再看到原因」。
  任务**分组**的顺序不动（按最近一次活动在前），页面上的报告 / 时间线也仍是**最新在前**（那是
  「眼下什么情况」，与文件读者要的不是一件事）。两处导出同一口径（`activity.ts` 的 `queryGroups`
  与 `manage.ts` 的 `exportGroups` 各翻一次，md / txt / xlsx 三种格式同时生效）。
- md 里的标签行写 `#标签`（**`#` 后不留空格**）：留了空格 md 就把它当一级标题渲染成一行大号字，
  比任务本身还抢眼；不留空格它只是一句普通文本，仍一眼看得出分块的开头。缩进用 **3 空格**
  —— 正好对齐 `1. ` 的内容列（缩 2 格会变成「懒惰续行」，缩 4 格直接变代码块）。
- txt **一个 md 符号都不用**（`【标签】` / `1)` / `·`）：它的用途就是丢给不认 markdown 的地方
  （记事本、工单），在那里 `# 标签` 会被原样显示、`- ` 是一串小横杠。
- **不加 BOM**：任务行文本一旦带上 BOM，首行 `- [ ]` 前面就多一个看不见的字符，
  再粘回批量新建框时那一条会解析不出来（看起来像「第一条丢了」）。
- xlsx **自己生成**（zip store + CRC32 + `inlineStr`，无第三方依赖）：为一个按钮往依赖里塞
  几百 KB 不值当，而它是用户明确要的格式。e2e 里**逐项校验 CRC** —— 下载成功不代表包是好的，
  CRC 写错时只有 Excel 会报「文件已损坏」。
- 所有时间戳在导出时换算成**绝对日期**（「今天 / 昨天 / 刚刚」这类相对词一律落成日期）。
  界面上写「今天 15:00 / 昨天」是对的（更好读），但那是**会变**的：今天导出的「今天」，
  明天打开就指错了日子，而文件要存档、要发给别人。
  **日期要从数据取，不能从文案取**（2026-09-20 修，管理页 xlsx 的「截止」列曾直接写
  `task.due`，于是「今天」进了文件）：日期读 `end`、时刻只有 `due` 文案里有（模型没有
  单独的截止时刻字段），见 `manage.ts` 的 `exportDue` —— 与 `markdown.ts` 的
  `taskToMarkdown` 同一条规矩。**反过来按 `due.includes("今天")` 倒推日期的那种写法
  已经炸过一次**（导出过 `⏰2026-明天`，见 `time.ts`）。e2e 逐格读 xlsx 的 D 列来验：
  它用 `inlineStr` + 不压缩的 zip 存字符串，单元格原文就躺在字节里，不用解压也读得到。
- **落文件前先问路径**（2026-09-20，`shell/download.ts`）。桌面端弹**系统「另存为」**
  （`@tauri-apps/plugin-dialog` 的 `save()`，默认名就是那份文件名、类型只给当前这一种），
  用户在对话框里选哪就写到哪 —— 以前的写法是 `<a download>`：桌面端它**不问一句**，
  直接落进 WebView 的默认下载目录，于是「导出成功」之后还得去找文件在哪。浏览器
  （`vite preview`、e2e）没有宿主对话框，仍走 `<a download>` —— 那里的「下载」本来就是
  浏览器自己的事，也能在 e2e 里验。
  **一个副作用必须处理**：fs 插件的作用域默认是**空的**（`tauri.conf.json` 里没有
  `plugins.fs.scope`），拿对话框给的路径直接 `writeFile` 会被 `path forbidden` 挡下 ——
  挡掉的正是用户自己刚点的位置。所以写之前先由 Rust 命令 `allow_save_path` 把
  **这一条**路径放进作用域：只放这一条、不放大整个目录，进程退出即失效。换成在配置里
  开一片通配作用域（`$HOME/**`）也能写，但那等于把「整个用户目录可写」永久挂在应用身上，
  而这里要的只是「这一次选中的那个文件」。取消对话框时**一个字节都不写、也不弹提示**
  —— 点了取消还要被提示一次，等于没让人取消。

### 4.10 分区与口令

- 分区是数据的**隔离边界**：任务属于某个分区，rail 底部切换，切换即换一批数据。
- **「我在哪个区」不用悬浮就看得见**（2026-09-20）。改之前 rail 底部那个切换器只有一个文件夹
  图标，当前分区的名字只在它的 `title` 里 —— **要悬浮才知道自己在哪**。现在：
  ① **图标下面写着分区名**（rail 是 64px，一行放得下四五个汉字；超长截断，全文仍在 `title`
  里），口令状态画在**图标那一行**（11px 的开锁 / 闭锁）；
  ② 弹层里当前项带**勾选标记** —— 不能只靠底色，底色同时被 `:hover` 用着，扫一眼分不出
  「当前项」和「鼠标正停在这一项上」；每项还带**条数**（选之前就知道那一区是不是空的）。
  锁分三态，按「用户需要知道什么」划：**不画** = 没设过口令（给没口令的区画一把锁，读起来
  像「它锁着」）、**开锁** = 有口令且本次已解锁（提醒它空闲后会自己锁上）、**闭锁** = 有口令
  且锁着（正常看不见 —— 锁屏盖着整页；从锁屏进设置重设口令时 `body.lock-suppressed` 让外壳
  露出来，那时才可见）。
  **锁的位置改过两次，两次都是「尺寸」问题，值得记**：先是挂在图标角上的 13px 角标 —— 那个
  尺寸下认不出是锁，看着就是个方点；挪到名字旁边又把名字挤成「演示空…」（56px 的名字行再
  加一把锁就超了）。最后落在图标那一行：这行有富余，锁能画到 11px。
  页头一度也有过一枚「分区章」，后来撤了 —— 夹在标题与操作按钮之间像贴上去的；分区显示
  放在 rail 上（常驻，且切换器本来就在那儿）。总览的问候条里**也**写分区名：那一行是
  「今天」的**一句话总结**（日期 · 星期 · 分区 · 条数），少一项就不成句 —— 它和 rail 上
  那处不算重复：一个是「我在哪个区」的常驻状态，一个是这一天的概述。
- 口令按分区设、空闲若干分钟无输入自动重锁。**它是隐私屏风不是加密保险箱** ——
  挡的是路过的人瞄一眼，不防拿到数据文件的人，所以**忘记口令没有找回入口**，重设即可。
- 口令的设 / 改 / 清合成**一个入口**：按钮文字恒为「设口令」，设没设靠**点亮**表示。
  以前按有无口令在「设口令」与「改口令 + 清口令」之间换按钮，用户每次都得先判断自己处在哪种状态，
  而答案只有他自己知道。点开是同一个弹窗：填新口令 = 换一个，留着不填 = 不改，`清除`（danger，靠左）。
  清口令**不再二次确认** —— 弹窗里那个按钮就是明确动作，口令随时能重设，没什么会因此丢掉。
- 锁屏与设置抽屉的层级：**抽屉压在锁屏之上**，侧栏仍被盖着（忘了口令要能进设置重设）。
- **完成后归档**（kv 的 `archive.afterDays`）是**分区属性**，收在分区行尾的「**自动 ▾**」里
  （默认收起 —— 四项分区各挂两个常年不动的 seg 会把设置页挤满）。**名字与档位都是 2026-09-21 改的**：
  **完成后：不归档 / 立即 / 7 天 / 30 天 / 90 天**，默认**立即**。
  - 旧名字（「自动归档」，第一档叫「关」）两处都错位：「自动」说不出**什么时候**自动（7/30/90
    也是自动），而「关」是**功能开关**、不是这条延迟刻度上的 0 —— 0 天那格的意思正是「立即」。
  - 判据 = **完成日 + N ≤ 今天**，即「**已完成的任务在任务页存活 N 天**」；「立即」= 0 天
    （完成的那一刻就收走）。**为什么用完成日而不是结束日**：旧规则建立在「结束日只可能晚于
    完成日」上，而这在「拖到最后才做完」时不成立 —— 于是两个方向都反（截止 09-01、今天才做完、
    7 天档 → **当场收走**；截止 12-31、今天做完 → 要赖到明年）。该等的是「**你完成之后**」，
    不是「截止之后」。完成日走 `completedOn()`：取 `setTaskStatus` 写的那条 `status → done`
    活动（没有则退化为结束日）。它原来在 `pages/overview.ts`（总览「本周完成」在用），归档判据
    是第二个消费方 → 挪进 `store.ts`。旧注释写「解析函数在 pages 层，store 够不着」，那是
    **位置**问题、不是**依赖方向**问题，挪一次文件就解决。
  - 编码：`0` = 不归档（系统永不自动收，只有手动归档才动它）、`-1` = 立即、`N` = 完成后 N 天。
    **负数是「立即」而不是「不归档」**，因为老版本里 `0` 就是「关」—— 沿用 `0 = 不归档` 才读得懂
    老数据；只有**没设过**的键才走新默认。读档那一步必须用**允许负数**的校验（`asArchiveRecord`，
    见 §3.3）：这一路的合法值包含负数，通用的数字校验会把它当脏数据丢掉。
  - 改档位会**立刻扫一遍**（拨完开关当场能看见哪些任务被收走）；手动「取消归档」的记在 `restored`
    里，本次会话不再被自动归档碰（`keepActive`）—— 否则刚恢复就没了。进度拖满导致自动完成、
    却因此没收走时，抽屉的 toast 会点明是这一条挡着（`isKeptActive`）：这是「明明做完了却没
    归档」的**第二种**来路，不说清楚就会被当成同一个 BUG 再报一次。
- ⚠️ **重算的时机与「逾期」一样**（2026-09-21 用户报的「已完成没有立马归档」）：`dataChanged()`
  里两件事一起做，每次变更都重算。以前只在**启动**与**改档位**时扫过，于是用户点完「已完成」
  什么也没发生 —— 看起来像这个设置不生效（其实生效了，只是要等下一次启动）。默认「立即」之下，
  这一步正是「勾完即离开任务页」的实现。
- ⚠️ **老库一次性冻结**（同一天）：默认改成「立即」之后，**没设过**的分区会拿到「完成即归档」——
  对一个用了很久的库，那等于开机把历史上所有已完成的任务一次收走（用户看到的是「我的任务少了
  几十条」，而他并没有改过任何设置）。所以 `bootStore` 在**库里本来就有数据**时，把当时缺席的
  分区**显式写成「不归档」**（= 它原来的行为）；**新装**（连同演示空间）才拿到新默认「立即」。
  ⚠️ 新装那条路必须把「立即」**落盘**，不能只靠读取时兜底（`archiveDays[id] ?? ARCHIVE_NOW`）：
  不写盘的话 `archiveDays` 一直是空的，**下一次启动就会被上面这条当成「从没设过」而冻成「不归档」**
  —— 新装用户第二次打开，设置里的「完成后归档」自己就变了个样。
- ⚠️ **进度与状态是同一个口径的两面**（2026-10-16 用户报的「进度满了却没进归档」）。判据一直是
  `status === "done"`（上面那条），而「把进度拖到 100%」当年**从来不碰状态** —— 于是一条
  「结束日已过 + 进度 100%」的任务被 `refreshOverdue` 标成「逾期」之后**永远收不进归档**，
  行首还挂着那枚「红色 ✓」（颜色说状态、形状说进度，见 §4.2）。用户实测反证：手动点一下
  「已完成」就当场归档 —— 缺的正是这一步连接。现在这条口径收敛成**一处**：
  - 规则本体住在 `data/status.ts`（`coreSetStatus` / `syncStatusForProgress` /
    `rollbackProgressForStatus` / `setProgress`），全是**纯函数**：不广播、不落盘、互不调用、
    幂等。放数据层是因为 `store` 的自愈扫描要复用同一份 —— 与 `completedOn`、时间基准同一条
    理由（`data/` 不许反向依赖 `pages/`）。`pages/shared.ts` 原样转出，四个既有调用点
    （任务页右键 / 总览勾选框 / 抽屉状态按钮 / 管理页批量）一行都没改。
  - **所有能改进度的入口都走 `setProgress()`**：抽屉滑杆与数字框（`progressControl.commit` 是
    共用提交口）、删除最新进度记录时的回退、就地编辑进度记录、md 写回、md 导入解析。少任何
    一处，那个入口就会重新造出「100% + 非已完成」。
  - **反向也要退**：状态从「已完成」改回未完成时，进度退到「**完成前那一格**」（活动时间线里
    最新那条「值 < 100 的进度记录的 `to`」，一条都没有就归 0）。不退的话，同一次 `dataChanged`
    里的 `syncStatusForProgress` 会立刻把它改回「已完成」—— 那个菜单项就成了空操作。
  - 抽屉是 **deferred**（点「发送」才落库），所以「拖满 ⇒ 完成」发生在**发送那一刻**；待保存
    提示在满格时会补一句「发送后自动标记为已完成」，免得用户拖完就走、什么都没发生。新建
    对话框是 `immediate`，那里拖满即生效。
- ⚠️ **老数据的自愈扫描**（同一天）：`store` 的 `refreshProgressStatus` 在**启动**与**每次
  `dataChanged`** 时跑一遍，顺序固定为「扫描 → `refreshOverdue` → `refreshArchive`」—— 反了
  会把「结束日已过 + 进度已满」的先标成逾期、再补完成。两处刻意的取舍：
  - **跳过已归档的**：归档 = 已离开工作面，不该被新一轮口径翻动（也就不会把管理页里的历史
    记录突然翻成「进行中」）；
  - **往「已完成」补时，活动日期取结束日**（不是 `nowStamp()`）：归档判据
    （`completedOn + N ≤ 今天`）与总览「本周完成」都读那条活动的日期。写成「升级那一天」会让
    7/30/90 天档的任务**再等 N 天**才走（与「结束时间已经到了」正相反），还会把一批历史任务
    算进本周。取结束日是相对最诚实的：这条任务「最晚那会儿就该做完」。
  首启扫到东西时，外壳给一条汇总 toast（照 `storageIssue` 的先例，见 §3.4）—— 不说的话，用户
  看到的是任务成批变色、成批从任务页消失，那看起来就是数据坏了。
- **新建的分区默认「立即」并且真的落盘**（2026-10-16 用户提的）：档位默认值平时靠读取兜底
  （`archiveDays[id] ?? ARCHIVE_NOW`），但**没落盘的值没有出处** —— 设置面板上写着「立即」，
  而 `archiveDays` 里根本没这个键。现在 `store` 订阅 `partitions.ts` 的
  `onPartitionListChange`（新增分区与启动读档之后都会发），把**缺失**的档位补成「立即」并单次
  `saveSetting`。它不能写在 `addPartition` 里：那个文件在依赖更下面一层，反过来 import `store`
  会成环。只补缺失这一条也同时保证了**老库冻结出来的「不归档」不会被碰**（那是已定义的值）。
  两道护栏都是踩过的坑：① 回调首行看 `archiveLoaded`，只有 `bootStore` **成功**读到档位才准
  写盘 —— `main.ts` 是 `await bootStore().catch(() => {})`，读档抛在 `loadSetting` 之前时
  `archiveDays` 还停在 `{}`，那时按「缺失」补一遍会把四个分区全写成「立即」，正好冲掉老库冻结
  那层保护；② 回调不走 `setArchiveDays()`（它内部 `refreshArchive + dataChanged`），启动期会
  在读档与首帧之间多插一轮全量重画与写盘。
- ⚠️ **一处顺序脆弱点**（查上面那个 BUG 时顺手发现，**当前不可达**）：`main.ts` 是
  `bootStore()` 在前、`bootPartitions()` 在后，于是「老库冻结」那段遍历的 `PARTITIONS` 此刻还
  是内置那四个 —— 自建分区因此**不会**被冻成「不归档」。这个正确性完全依赖那两行的顺序；真要
  调过来，先看 `archiveLoaded` 那道护栏，并把 `partitions.ts` 的 `addPartition` 注释一起改。
- **归档之后的表现**：任务页不再列它（这正是「完成即归档」想要的效果），管理页是唯一能看到它的
  地方（「已归档」那一档 + 行尾那枚「已归档」）。总览为此专门摆了第五张卡「**归档**」（见 §4.1）：
  四张状态卡仍是未归档口径、归档数单独一张 —— 收走的那批始终看得见，「已完成掉数」也总能对上
  「归档涨数」。（活动分析的口径本来就含归档，源码里写明「归档只是列表里不再显示」。）
- ⚠️ **重新打开一条已归档的任务 ⇒ 它离开归档**（2026-10-17 用户报的「任务丢了」）：手动改状态、
  拖回进度、删/改最新那条进度记录、按 md 写回 —— 只要状态从「已完成」退下来，`dataChanged()`
  都会把它放回任务页。没有这一条时用户看到的是：任务页（只列未归档）没有它，管理页又常停在
  「已完成 / 进行中」那档（状态已变成进行中或逾期）也筛不出它，**两处都没有**；而清归档的唯一
  入口（手动「取消归档」）只在管理页。
  - 实现是 `store` 的 **`refreshReopened`**：内存里留一份「上一次广播时各任务的状态」快照，只认
    `done → 非 done` 的跃迁。**不能**写成「`archived && status !== done` 就取消归档」—— 管理页
    允许把任何状态手动收进归档（§4.1），那样写等于下次 `dataChanged` 把它们全放回来。
  - 顺序固定为「`refreshProgressStatus` → `refreshOverdue` → **`refreshReopened`** → `refreshArchive`」：
    逾期先落地，所以「结束日已过、改回进行中」落到 `overdue` 也算一次重新打开；放回工作面之后
    才判自动归档。
  - 只活在内存（不落库、不改表结构）：启动后第一次广播时快照是空的，历史归档一条都不会被翻动，
    延续「不翻动已归档记录」的口径（`refreshProgressStatus` 跳过已归档仍是原样）。
  - 不记 `restored`、也不再提示「取消过归档」：状态已不是已完成，自动归档本就不会收它；用户若又
    把它拖满 100%，仍按档位当场归档（这正是「立即」该有的效果）。
  - 抽屉的 toast 会补一句「已从归档放回任务页」（`progressShiftNote` / `onStatusChange`）—— 那一下
    同样会让「背后的行」出现或消失，不说清楚就会被当成任务丢了。
- **历史遗留的一次性归位**（2026-10-17 补）：`refreshReopened` 只认**当场**的跃迁，1.0.1 时期就写坏的
  「已归档 + 未完成」不会自己好。启动时（`bootStore`，**排在 `refreshProgressStatus` 之前**）跑一次
  `healLegacyArchived`：只认「已归档、状态不是已完成、且最近一条 `status` 活动是 `from === "done"`」
  的任务，把归档位撤掉。跑过就把 kv `archive.reopenHealed` 置 true，之后不再执行 —— 否则用户
  「重新打开后又手动归档」的决定每次启动都会被推翻。代价是那极少数任务会被放回一次（用户已确认接受）。
  结果并进 `progressHealReport` 的 `restored`，`main.ts` 弹一句「已把 N 条重新打开过的任务从归档放回」。
- **系统派生的状态记录不进时间线**（2026-10-17）：`syncStatusForProgress` 写的那条 `status` 活动带
  `auto: true`（进度拖满 / 退回都算）；抽屉 `renderTimeline` 过滤掉它，`N 条` 也只数可见的。
  **数据仍保留**：`completedOn()` 与「完成后 N 天归档」读的就是最新一条 `to === "done"` 的状态记录；
  总览「近期活动」、活动分析报告与导出仍是台账，照旧包含它（隐藏会改统计口径）。
  显式点「已完成」时 `setTaskStatus` 走 `setProgress(…, false)` 再 `coreSetStatus`，保证那条是**真实**
  记录、不会被当成系统噪音藏掉 —— 这是 `setProgress` 多出第三个参数的唯一理由。
  旧的派生记录没有 `auto` 标记，无法回溯识别，仍会显示。

### 4.11 设置抽屉

**一页到底**，四组：外观 → 启动 → 分区 → 关于。不再分页签 —— 设置项不多，分页签只是多一次点击。

- 关于面板按**实际功能**写，**两组各三条**（2026-10-16 从十条精简：它就压在抽屉最下面，没人当正文读完，而这一块本来也不是功能说明书；砍掉的不是没做 —— 分区口令、Markdown 迁入都还在，只是不值得在这个宽度里逐条列），不写技术栈、不提旧版本。
- **「关于」组最终四行**（2026-10-17 定稿）：

  ```
  关于（v1.0.3）        展开 ▸     ← 标题里带版本，介绍块可折叠
  帮助文档              打开 ↗      ← 常驻动作，不参与任何折叠
  检查更新              [检查更新]
  下载与反馈            展开 ▸      ← 自己的折叠头，与「关于」互不联动
  ```

- **版本号只留一处**：并进组标题「关于（v1.0.3）」，由 `getVersion()` 回填、拿不到时用常量兜底。单独的「版本」行撤了 —— 它和「检查更新」上下挨着，读起来像两个都能点的东西；「运行环境」行也撤了（日常无用，系统要求写进帮助文档即可）。
- **两个折叠头各管一块**：`#set-about-head` 只控制介绍块（`#set-about-intro`），`#set-about-downloads` 由 `#set-downloads-head` 单独控制；状态分别记在 `settings.aboutOpen` / `settings.downloadsOpen`，箭头按**自己那个头**的 `aria-expanded` 旋转。默认都收起。介绍块里是**下载渠道**（阿里云盘(国内推荐) 在前，GitHub Releases 备选，与 README「国内下载」同源）与**反馈与联系**（微信公众号 `Pyvan` 点击复制、GitHub 仓库点击开系统浏览器）。渠道顺序按**能不能打开**排，不按官方不官方。
- **「帮助文档」是常驻行**：`button.set-row.set-row-link#set-help`，整行可点、右侧「打开 ↗」，不随「关于」或下载行的开合变化。点击走 Rust 命令 `open_help`，用系统默认浏览器打开随程序嵌入的帮助文档（见下）。
- **云盘按钮下面有一行补救小字**（`#about-tip`，11.5px）：`链接失效？换 GitHub Releases，或公众号 Pyvan 留言。` 两个行内动作分别走 `openExternal` 与 `copyText`。**不做链接探测**：实测阿里云盘对失效分享同样返回 200、响应体只有十几字节（脚本渲染 + 反爬），没有可靠判据；探测只会多一次联网、一次延迟和误报。也**不做警告色 / 背景块** —— 链接多数时候是好的，常驻一块警示色反而像下载坏了；文案只有一句，e2e 卡 40 字上限防止又长回去。
- **检查更新**是独立一行，**只有点它才联网**（不做启动时的静默检查：一个本地优先、无账号的工具，没有任何理由每次启动都偷偷发一个请求）。2026-10-17 起它升级成**应用内更新**（官方 `tauri-plugin-updater`），交互固定**两步**：第一次点击查 `latest.json`，显示强调色「发现新版本 vX · 日期」，按钮换成「下载并安装」；**再点一次**才下载（状态行写百分比），下载完应用被安装器退出、装完自动拉起新版。**检查失败只有一种出口**（「已是最新版本」，网络不通 / 404 / 清单损坏都算），**下载失败**才明确说「更新失败 · 可手动下载」并展开「下载与反馈」—— 用户已经表达过更新意图，静默吞掉才是误导。
- 发现新版本时：只把**下载与反馈**那一行展开（不写 `settings.downloadsOpen`，看完可收起）、打上「有新版本」标记，并在渠道块里补一行「发布日期 + 版本号 + 查看更新说明 ↗」，把国内渠道点亮（只变色、不动几何）。结果回到「已是最新」时清掉标记、日期与高亮。**不展开「关于」介绍块** —— 用户点的是检查更新，不是想读产品简介。
- **更新源只走 GitHub**：`https://github.com/HananxR/Tadado/releases/latest/download/latest.json`。清单由 `npm run updater:manifest`（`desktop/scripts/updater-manifest.mjs`）在构建后生成，含版本、说明、`pub_date` 与 `windows-x86_64` 的 `signature` / `url`：`signature` 直接取安装器签名文件（当前是 `*-setup.exe.sig`；若改用 v1 兼容产物则是 `*.nsis.zip.sig`）的内容，`url` 指向同一个 Release 上的那个安装器。**清单与包必须同属「最新正式 Release」**，否则 `releases/latest` 取不到。阿里云盘不再是更新源（分享页是脚本渲染的、没有可用 API），只在下载失败时充当手动出口。
- **签名校验是硬门槛**：公钥写在 `tauri.conf.json` 的 `plugins.updater.pubkey`；私钥 `tauri signer generate` 生成、**不进仓库**（本机在 `%USERPROFILE%\.tauri\tadado.key`，口令在同目录的 `tadado.key.password`，另存密码管理器 + 离线备份）。换密钥 / 用错密钥时，已装旧版会拒绝新包 —— 这是机制本身，不是配置错误。**丢失兜底**：手动下载渠道永远在，让用户手动装一次新正式版（新版带新公钥）即可恢复自动更新。
- **安装走 `installMode: "passive"`**：本地定制的 `windows/installer.nsi` 所有页面都带 `SkipIfPassive`，`PageLeaveReinstall` 也写明「被动模式没有界面，升级按推荐项（原地升级）处理」—— 更新不会卡在维护页，默认原地升级、`%APPDATA%` 里的数据不动。
- **版本比较交给插件**（`tauri-plugin-updater` 内部按 semver 比，前端不重算）。v0.x 那段「按 3 段截断把 `0.1.2.4` 读成 `0.1.2`」的历史随 `src-tauri/src/update.rs` 一起删掉了；`shell/update.ts` 只剩「检查 / 下载安装 / 进度」三段门面，浏览器里仍老实回「仅桌面版可检查更新」。
- **适用范围**：只有**安装版（setup.exe）**能应用内更新；便携版没有安装目录与卸载项，updater 只会去跑 NSIS 安装器（等于装一份新副本），所以文档口径统一为「推荐安装版」，便携版仍手动下载。**1.0.1 及更早的版本没有 updater**（1.0.2 是同版本重发、带上了 updater），装过 1.0.2 的机器之后才有应用内更新。
- 外链一律走 `shell/links.ts`（桌面端 `plugin-opener`，纯网页预览回落 `window.open`）：地址只在那一处，换渠道不必满仓库找旧链接。
- 「窗口置顶」不在设置里重复一份：标题栏的图钉是唯一入口。
- 启动组：开机自启、全局热键。

**帮助文档**（`desktop/src-tauri/resources/help/` + `src-tauri/src/help.rs`）：

- 单一 HTML 源文件 + 单个 `<style>`：无脚本、无外链字体 / 样式、无行内 `style`，图片是本地 1x JPEG。**不与 Markdown 混排** —— 两套标题 / 列表 / 代码样式会互相覆盖。
- 内容固定五块：目录、各模块功能定位与操作、FAQ、版本更新简述、数据与备份；目录锚点、页面名、快捷键、当前版本由 `resources/help-check.mjs`（`npm run check:help`）核对，负向清单挡住已撤掉的能力。
- **不用 `bundle.resources`**：便携版交付的是裸 `Tadado2.exe`，附属文件不在安装目录就 404。改用 `include_str!` / `include_bytes!` 编进二进制，`open_help` 首次打开时写到 `app_cache_dir()/help/<version>/`（`.ready` 标记避免重复写盘）、把 `{{VERSION}}` 换成当前版本，再交给 `tauri_plugin_opener::open_path`。缓存按版本隔离，升级自动换新。
- 截图只能来自**演示空间的样例数据**：`resources/screenshots.mjs` 用独立浏览器上下文（localStorage 为空 → mock），并在拍之前断言「优化列表页性能」确实在，防止误连真实库。

---

## 5. 外壳

### 5.1 标题栏

- 无边框 + 自绘，`data-tauri-drag-region="deep"` 让子元素区域也能拖。
- 左侧**只有软件名**：图标与版本号徽章都撤了（版本号在设置 → 关于里，标题栏放它是装饰）。
- 右侧：置顶图钉、主题切换、最小化 / 最大化 / 关闭。
- 关闭 = 收起常驻，真正退出走托盘菜单（`app_exit`）。

### 5.2 托盘、热键、自启

- 托盘由 **Rust 侧**在进程启动时创建（`lib.rs`），前端只通过 `trayBridge.ts` 接收事件后切页 / 开设置。
- 托盘菜单：显示窗口 / 设置 / 退出 Tadado2。**「新建任务」撤了**（2026-09-17）—— 点它只能把主窗口
  叫出来再弹浮层，不值当。
- 全局热键 `Ctrl+Shift+Space` 唤起 / 收起主窗口。它挂了窗口收起后就唤不回，所以 `setupHotkey`
  失败必须告警。注册**必须幂等**（`isRegistered` → 先摘再注册）：Rust 侧那份注册不随 webview
  重载消失，直接再 `register` 会以「已注册」失败，于是每次重载都弹一句**假的**「热键不可用」。
- **跨日续跑**（`rollover.ts`，2026-09-20）：`TODAY` 是模块加载时算一次的（日期坐标见 §3），
  而托盘常驻意味着「**开着过夜属于正常用法**」—— 不处理的话，过了午夜整个应用还停在昨天：
  逾期不重算、新建任务的默认截止是昨天、新活动的时刻也记在昨天；而问候语与轴上的「现在」
  读的是真实时钟，于是页面自相矛盾（问候语说「早上好」，日期还停在昨天）。
  做法：每分钟看一眼本地日历日，变了就 `location.reload()` —— 走的是**设计里已经有**的那条路
  （托盘与热键由 Rust 侧持有、与 webview 生命周期解耦，重载本就在预期之内，见 §1.3 表格上方
  那句注释）。两条约束：① 只在 Tauri 里跑（预览与 e2e 没有常驻这回事，而且跑到一半被重载更
  难查）；② 有**未提交的输入**（模态框、抽屉里那段还没点「按 md 更新任务」的 md）时跳过，
  等下一分钟 —— 批量草稿走 kv 丢不了，那段 md 没有别的地方存。
  为什么不把 `TODAY` 改成取值函数：那要动数据层与二十来个调用点，且每个派生值都得各自订阅
  一次「跨日」，漏一个就回到同样的问题。
- 单实例：第二次启动由已运行实例接管并拉回窗口。
- 开机自启用 `tauri-plugin-autostart`；浏览器里降级为「不可用」并明确标出。

### 5.3 浮层与提示

- **右侧抽屉互斥**（`panels.ts`）：设置与任务抽屉同时最多一个。
- **点面板外收起**（`panels.ts`，2026-10-16 加）：两块抽屉都没有遮罩，鼠标按在面板**之外**
  （页面内容、侧栏、标题栏……）就收起它，不必每次都去够右上角那个 X。判定收在 shell 层，
  是因为两块抽屉的规则一模一样，各写一份迟早分叉；为此 `registerPanel` 多收一个**根节点
  getter**（任务抽屉的 DOM 是懒建的，注册时还不存在）。面板层仍然只认「id / 根节点 /
  怎么关掉我」，不认识任务域。
  豁免分两类。**按层**的：`.mask`（模态铺满全屏且盖在抽屉之上，不豁免会「点一下关掉
  两样东西」）、`.lock-screen`、`.ctx-menu`（右键菜单，否则点「打开维护抽屉」会先把当前
  抽屉关掉、闪一下再开）。**按手势**的：`[data-panel-keep]` 标的是「打开抽屉的那只手」
  （任务列表的行、侧栏的设置入口），点它们不算「点在外部」，否则「同一条任务再双击一次
  收起」会当场失效：双击的第一下先关、第二下 `isTaskOpen` 已是 false 于是又开，净效果是
  闪一下、抽屉还开着。这个属性的值必须是它服务的那块面板的 **PanelId**（`"task"` /
  `"settings"`），只有「当前开的正是它」时才豁免 —— 写成裸属性等于空串、不豁免：任务行
  对设置抽屉来说不是打开手势，设置开着时点一行，那就是真的点了外面，该收起来。
  只认鼠标左键（右键是「要处置这一行」，不该顺手关抽屉）；用 `pointerdown` 而不是 `click`，
  与 ctx 菜单 / 下拉菜单的既有选择一致，也不会和任务页甘特拖动后的 `swallowAfterDrag`
  （在 capture 阶段拦 click / dblclick）相互干扰。
- 破坏性操作二次确认（`confirm.ts`）：默认焦点给「取消」，Esc 与点遮罩都算取消。
- 提示条（`toast.ts`）显示 2200ms；用于「已导出」「口令错误」这类结果回执。

### 5.4 主题与配色

- 主题 `light` / `dark` / `sys`，落在 `data-theme`；首帧前就要定好，否则闪白。
- 热力图配色方案落在 `data-scheme`（`tadado-heat-scheme-v2`），即改即生效。
- 配色方案的取值只能来自 `tokens.css` 里的色阶变量。

---

## 6. 构建与验证

### 6.1 脚本与 CI

```bash
cd desktop
npm install
npm run dev            # 纯前端预览（非 Tauri 环境自动降级，只能调样式）
npm run build          # tsc + vite build，产物在 dist/
npm run e2e            # 构建 + 真浏览器冒烟（唯一的验证闸门，约 2–3 分钟）
npm run tauri dev      # 真实窗口（首次需编译 Rust，约 1–2 分钟）
npm run tauri build    # 出安装包：NSIS + MSI → src-tauri/target/release/bundle/
npm run updater:manifest  # 构建后生成 bundle/latest.json（缺包或缺签名会非零退出）
```

**打包必须先设签名环境变量**（`bundle.createUpdaterArtifacts = true` 之后没有私钥会直接失败，
这是有意的护栏 —— 免得发出一个「能下载、装不上」的更新包）：

```powershell
$env:TAURI_SIGNING_PRIVATE_KEY = "$env:USERPROFILE\.tauri\tadado.key"
$env:TAURI_SIGNING_PRIVATE_KEY_PASSWORD = [System.IO.File]::ReadAllText("$env:USERPROFILE\.tauri\tadado.key.password")
cd desktop; npm run tauri build; npm run updater:manifest
```

`npm run e2e` 的第一件事是 `tsc && vite build` —— 构建不过直接停在那里，所以它同时是
类型检查的入口。**跑法**（2026-09-21 定的）：一轮改动全写完再跑一次，不要每改一处就跑；
动手前先 `npx tsc --noEmit` 把编译错误挡在前面；用户没让跑就不跑（理由见 CLAUDE.md）。

另外两个脚本在**仓库根**、与 README 同级 —— 它们服务的是整个仓库，不属于应用本身：

```bash
node resources/skill/tadado-activity-import/scripts/migrate-activity.mjs <清单>   # 旧数据迁移，见 §7.1
node resources/screenshots.mjs           # 重拍 README 的界面截图（先 cd desktop && npm run build）
```

`npm run dev` 下 `@tauri-apps/api` 的窗口调用走本地状态降级，页面与样式都能看，
只有窗口行为（置顶、托盘、热键）不可用。

CI（`.github/workflows/desktop.yml`）在 `desktop/**` 有改动时跑 `npm ci` → `npm run build`
→ `npx playwright install --with-deps chromium` → `npm run e2e`。

### 6.2 冒烟测试覆盖什么

`e2e/smoke.mjs` 真开一个 Chromium（1280×860）把主流程点一遍，分这些组：

> 切页只显示一页 · 四张表都能翻页且档位一致 · 交付口径（启动状态演示空间 100 条）·
> 总览的数字必须点得开且对得上 · 任务页（甘特窗口固定 32 天 / 拖动平移 · 搜索 ·
> 排序 · 状态与优先级筛选）· 维护抽屉（md 预览与写回 · 时间线正序 · 进度的草稿式提交与再编辑 ·
> 记录的编辑与删除）· 起止倒挂校验（两个方向越界都拒收并回退显示 · 同日放行 ·
> md 里的倒挂截止不写回）· 完成后归档（默认「立即」＝勾完即离开；「7 天」＝在任务页存活）·
> 标签 · 图谱 · 写法里的活动行 · 导出（md/txt/xlsx）· 数据迁入（含同名提示与「起点按截止日
> 修正」的逐条列出 · 导入后库里没有倒挂区间）·
> 分区口令与锁屏 · 一屏到底 · 存储版本与读入口归位 · 老存档归位 · 无 console 报错

几条**判据的写法**本身是设计的一部分：

- 「一屏到底」判的不是「没有滚动条」，而是**不用滚就能看到列表与翻页**：页面自身不滚动
  （`scrollHeight - clientHeight ≈ 0`）、列表区真的吃到了剩余高度、工具行 / 分页器的下沿还在视口里。
  三条缺一条，用户就还得滚。
- 「总览的数字 = 点开后的行数」比的是**筛出来的总数**而不是当前页行数 —— 100 条之后一页装不下，
  比行数会误判。名单**从界面上取**（遍历 `.tile`），不写死清单：写死的那次列了三张卡，于是
  第四张「今日到期」的 `onClick` 漏了都没人发现 —— 一条只在某些钟点红的断言会被当成时钟问题，
  一条漏了对象的断言则**永远不会红**。
- 「导出的 xlsx」逐项校验 CRC；「导出的 md / txt」校验三层数量一一对应。
- 跟时间沾边的断言有**三条规矩**（这一类踩过三次，每次红起来都像是被测代码坏了，见
  `smoke.mjs` 顶部）：要「今天」就**现算**；跟本地日历日对齐**别用 `toISOString()`**
  （那是 UTC，东八区每天 00:00–08:00 会算成昨天 —— 有一条断言就这么每天早红八小时）；
  挨着日期边界的断言**容许「上一步的值」**（上一分钟 / 上一小时 / 前一天）。文件顶部另有
  `localIso()` / `todayDay()` / `nearestDay()` 三个助手（2026-10-16 又加了 `isoOfDay()` /
  `dayOfIso()`：日期框收 ISO 串、判据比的是天数，两边口径不一致时测试会以为自己填的是另一个
  日子），日期比较一律走它们（数据里不存年份，直接比 `"MM-DD"` 会在 1 月倒挂）。判据是
  「**将来还会不会自己红**」，不是「今天跑不跑得过」。
- 最后一条永远是「**无 console 报错**」。

2026-09-21 那一轮（凌晨跑，red 了 12 条）又添了四条规矩，都是**断言自己的前提**出了问题：

- **口径要跟被测代码一致，不是「跟常识一致」**：判「今天」得按应用的约定算
  （`TODAY = Date.UTC(本地年月日) / DAY_MS`，见 `data/time.ts`）。写成 `Date.now()` 折天、
  或 `toDateString()`，量到的是**真实 UTC 时刻**那一天 —— 本地 00:00–08:00 正好差一天，
  「昨天的活动」会被判成今天。
- **别把「当前时刻晚于种子里的时刻」当前提**：演示数据的「今天」活动带**固定钟点**
  （最早 06:20），凌晨跑时那些时刻还没到，于是「新建的任务排在第一条」这类断言不成立。
  要量的是「它出现在第一页里」—— 那才是「事件驱动重画」想证明的事。
- **前提要自己造**：分页器在只有一页时不摆翻页件 —— 断言若直接去点 `▶`，等的是一个不存在的
  元素，表现是**超时 30 秒**而不是一条看得懂的失败。这一条当年要「先切『全部』把页数造出来」
  （默认档位「本周」常常不足一页）；档位撤了之后，任务页默认就是「全部未归档任务」、页数天然
  够，但**规矩没变**：点翻页件之前先确认这一屏真的多于一页。
- **别只数个数，要量不变量**：这条教训来自已经拆掉的档位分段（`.seg-in`）—— 那时「有几个高亮段」
  取决于那批任务的条形有没有跨过那一格（逾期任务的条整段在今天之前，合法地没有高亮段）→
  随数据时红时绿，改成结构性不变量才立得住。**那些高亮后来全撤了**（见 §4.2），这条现在的
  对应物是「条上分段必须**恒为 0**」（`segSplit === 0`）—— 它钉的是「别再退回一条上叠两个量」
  这条路本身，而不是某个会随数据变的数字。

一句话：红了先分类 —— 是被测行为真的变了、断言的前提没设对、还是数据本身随时间变。
后两类改断言，第一类才改代码。

### 6.3 为什么必须有 e2e

`tsc` 看不出运行时故障。曾经 `dropdown.setValue` 的回调 `onPick` 造成无限递归
（`paint → setValue → onPick → paint`），抽屉节点建出来了却永远加不上 `.open`，
表现是「双击、右键都没反应」—— 类型完全合法、构建照过，只有真点一遍才暴露。

自动化做不到的部分：**样式与视觉的对照**。改完样式记得对着
[`resources/ui-mockup/tadado-2.0.html`](resources/ui-mockup/tadado-2.0.html) 核一遍差异。

### 6.4 README 的截图（`resources/screenshots.mjs`）

README 里的界面图是**真拍**的，不是示意图 —— 对着一份真实构建跑 Playwright，产到
`resources/screenshots/`。三条规矩：

1. **视口 1180×760**，正是应用窗口的默认尺寸（§2.4）。截出来的就是用户打开软件看到的那一屏。
2. **画面里的数据就是演示空间那 100 条样例**，不另造数据：README 展示的必须是用户装上就能看到的东西。
3. **抽屉那张挑时间线最长的任务**（用搜索框定位到「优化列表页性能」，8 条记录）。列表默认按
   截止排序，前十几行全是压测生成的任务、每行只有一条「创建任务」—— 而那张图要证明的正是
   「过程留在时间线上」，拍空历史等于没拍。

同一份脚本还拼出**仓库的社交预览图**（1280×640，`social-preview.png`）：品牌 + tagline +
一张缩小的真实界面，在 GitHub 的 Settings → Social preview 里上传。

**帮助文档的截图**是同一趟跑出来的另一套产物（2026-10-17）：独立浏览器上下文、1x JPEG（质量 80，
宽度 1180），写到 `desktop/src-tauri/resources/help/img/`。它必须只含演示空间的样例数据 ——
所以截图前先搜「优化列表页性能」并断言命中，命中不了就报错退出，绝不把本地数据库里的真实任务
拍进随程序分发的文档。八张：overview / tasks / drawer / graph / activity / manage / task-form /
settings-about（最后一张核对「关于」标题带版本、无运行环境行、帮助行与下载行都在）。

改完界面记得重跑一次 —— 图和界面不符比没有图更糟；帮助文档另有 `npm run check:help` 核对文案
与功能是否还对得上。

---

## 7. 迁移与交付

### 7.1 从 v0.x 迁移数据

路线：v0.x「活动分析」导出清单 → `resources/skill/tadado-activity-import/scripts/migrate-activity.mjs` 转成任务行 →
v1.x **任务管理页 →「数据迁入」** 导入。**人手写的活动时间线一起过来**。工具的用法、对账口径与
导入后的复核见 [`resources/skill/tadado-activity-import/SKILL.md`](resources/skill/tadado-activity-import/SKILL.md)。

工具的设计原则是「**先对账，再动手**」：默认只解析只报告、不写文件；认不出的行必须停下问；
源里每一行活动都要有去向（输出 / 并入续行 / 重复剔除），数字不平不许写出。

> 转换器的输出必须能被**下游真正的解析器**验一遍。这条来之不易：截止挖掘用了两条正则，
> 后一条把前一条的结果覆盖回箭头**左边**的旧日期（`截止时间 A -> B` 读成了 A）——
> 靠「把转换结果再喂回应用数一遍」才看出来。**「数量对」不能只是转换器自己说的。**

带不过来的一律要在导入前说明：**「逾期」**（由截止日期自动算）、优先级；还有旧版**自动写下**
的系统记录（创建 / 延后 / 状态变更 / 进度变更）—— 转换工具会把它们滤掉，
因为那几项的**结果已经写在任务行上**（方括号 = 状态、`::` = 进度、`⏰` = 截止），而活动行导入后
一律是「人手写的一条进展」，带进来等于给系统记录挂个人名。想全留着用 `--keep-system`（默认不开）。
⚠️ **顺序**：截止与状态正是从这些行里**挖出来**的 —— 工具是**先挖字段、再滤行**；先滤就把字段丢了。

**创建日与起止不用旧版记账，但也不是导入日**：应用在导入时把 `created` 与 `start` 都落成
**这条任务最早一条活动的日期**（没有活动行才落导入日，见 `data/markdown.ts` 解析收尾那段）。
理由：迁进来的任务历史全在活动行里，一律落导入日会把整批任务的「创建于」挤在同一天 ——
**创建日是这次迁移里唯一会被整个抹平的时间维度**（截止 / 进度 / 状态都能从活动文本里挖到）。
代价是它给的是「第一次动手」那天，不是真正的创建时刻（旧版的「创建任务」记录按上面的规矩
不转移）；`start` 一起改，否则会出现「创建于 01-05、甘特色条却从 09-20 才开始」这种矛盾。

### 7.2 交付口径

- **演示空间 100 条**（28 原型手写 + 72 生成），其余分区为空 —— e2e 有断言锁住。
  100 里有 1 条是**已归档**样例（管理页的归档列要有东西看）；**其余已完成的那些会在启动时
  被「完成后归档＝立即」（§4.10）一并收走** —— 所以任务页列出的**明显少于 99 条**。e2e 因此钉的是
  恒等式「未归档 + 归档 = 100」与「归档明显不止样例那 1 条」，**不写死两个数**：生成的那 72 条里
  已完成多少由生成器决定，写死就会一改生成逻辑就红。
- **三个产物**（`npm run tauri build`）：`bundle.targets = "all"` 在 Windows 上给的是 **NSIS +
  MSI** 两个 —— `nsis/Tadado2_<版本>_x64-setup.exe`（约 2.6 MB）、
  `msi/Tadado2_<版本>_x64_en-US.msi`（约 3.6 MB）。开了 `createUpdaterArtifacts` 之后，两个安装器
  各会多出一个签名（`*-setup.exe.sig` / `*.msi.sig`）：**应用内更新用的是 NSIS 那个**，
  `npm run updater:manifest` 把它的签名与下载地址写进 `latest.json`。**便携版不在 bundler 的目标里**，两步手工：
  ① 把 `target/release/desktop.exe` 复制到 `bundle/portable/Tadado2.exe`（主程序二进制叫
  `desktop`、产品名才叫 Tadado2）；② 压成 `bundle/portable/Tadado2-portable.zip`（约 3.3 MB）。
  **交付出去的是那个 zip，不是裸 exe**：7.7 MB 的 exe 直接发出去，用户拿到的是「一个不知道
  要不要双击的东西」；zip 3.3 MB，一眼就知道是「下载 → 解压 → 运行」。
  **每次打包都要记得这两步** —— 漏了第一步，README 承诺的便携版会是一个旧二进制；漏了第二步，
  zip 里装的是上一次的 exe。两种都**看不出来**（名字一样）。
- **第四份交付物是 skill 包**（`tadado-activity-import.zip`，约 15.6 KB）—— 它**不是应用的产物**，
  而是给用户的那份「旧数据迁移」skill：用户下载 Tadado2 之后，要让自己的 Claude Code / Codex
  帮忙搬旧数据，就得有它。压的时候**压目录本身**（`-Path resources\skill\tadado-activity-import`），
  让 zip 里保留 `tadado-activity-import/` 这一层 —— 少了它，解压到 `.claude/skills/` 是加载不到的。
  它不跟 `npm run tauri build` 走，也不在 `bundle/` 里自己出现，**每次发版都要记得手工压一次**。
- **应用内更新还要一份清单**（2026-10-17）：`latest.json`。它由 `npm run updater:manifest` 生成
  （版本、说明、`pub_date`、`windows-x86_64` 的签名与下载地址），指向的就是**同一个 Release 上的
  `*-setup.exe`**（安装器的签名在 `*-setup.exe.sig`）—— 不需要另做一个更新包。
  **清单必须和安装包进同一个 Release**，且那个 Release 要是「最新的正式版」——
  `releases/latest/download/latest.json` 才取得到。漏了清单：所有客户端都「检查不到更新」。
  清单里的签名必须来自**同一次构建**，这是脚本存在的原因。
- **用户数据不在安装目录里**，所以重装 / 换路径 / 升级都不影响它。这是设计使然，不是碰巧：
  Tauri 按 `identifier` 定位 `app_data_dir`，值就是 `%APPDATA%\com.tadado.app\tadado.data`
  （`db.ts` 里是 `Database.load("sqlite:tadado.data")`）。**这也是 `identifier 不能改` 的
  真正原因** —— 改它等于换一个数据目录，在用户眼里就是「数据没了」。
  两条安装路径的实际行为，都是从**生成的脚本**里核的（`target/release/nsis/x64/installer.nsi`、
  `target/release/wix/x64/main.wxs`），不是照惯例推的：
  · **NSIS** 默认 `currentUser` → `%LOCALAPPDATA%\Tadado2`（免管理员）。**从 1.0.1 起改用定制模板**
    （`src-tauri/windows/installer.nsi`，取自 CLI `tauri-cli-v2.11.4` 的官方模板，仅两处改动）：
    ① 升级（检测到旧版本）**仍显示维护页**，推荐项「原地升级（保留任务与分区数据）」放在第一项并
    默认勾选；选它不执行旧卸载器（删数据的代码只在卸载段），选第二项才「先卸载再安装」。
    同版本重装、降级保持原询问页，MSI/WiX 迁移仍强制先卸载。**应用内更新走被动模式**（`installMode`
    设成 `passive`）：所有页面带 `SkipIfPassive`，`PageLeaveReinstall` 也写明「被动模式没有界面，
    升级按推荐项（原地升级）处理」—— 于是更新不会卡在维护页，数据不动。② 卸载页「删除应用数据」复选框**保留且默认不勾**，
    **勾选后二次确认**（说明会永久删除任务/分区/设置且不可恢复，默认按钮「否」，选「否」留在本页）。
    卸载时**只有同时满足两件事**才删数据：勾上「删除应用数据」**且**不是升级（`UpdateMode <> 1`）。
    模板与 CLI 版本绑定：升 `@tauri-apps/cli` 必须按新 tag 重新同步模板并重放两处补丁（失配会在
    构建期报错，不会静默产出坏安装包）。
  · **MSI** → `%ProgramFiles%\Tadado2`（需管理员），`MajorUpgrade AllowDowngrades="yes"`：
    自动顶掉旧版本、也允许装更旧的版本。它的卸载只删自己装的目录与快捷方式，全文**不出现**
    `%APPDATA%\com.tadado.app`。另外它会回头找 NSIS 写下的注册表键
    （`HKCU\Software\tadado\Tadado2`）—— 先装过 NSIS 版再用 MSI 装，会落在同一个目录。
- **降级读新库安全，但会丢新字段**：`runMigrations` 只跑 `from === current` 的步骤 —— 版本号
  比链子新就一步不跑（不报错、不改库），数据照读；但旧程序下一次保存会把它**不认识的新字段
  写没**（保存是全量覆盖）。所以「装回旧版本看看」可以，别在那之后继续录数据。
- `<productName>` = `Tadado2`，版本从 **v1.0.0** 起（与 Py 版区分）。
- 版本号四处一起走：`tauri.conf.json` / `Cargo.toml` / `package.json` / 设置里的兜底值。
  Tauri 回话给的是纯数字，显示时补 `v` 前缀。

---

## 8. 附录

### 8.1 关键常量（`src/pages/shared.ts`）

| 名称 | 值 | 说明 |
|------|-----|------|
| `PAGE_SIZES` | `[20, 30, 50, 100]` | 四张表共用的每页档位 |
| `PAGE_SIZE` / `TASK_PAGE_SIZE` / `REPORT_PAGE_SIZE` | `20` | 各自的默认档位 |
| `FEED_LIMIT` | `50` | 总览近期活动的**上限**：这张卡只摆最近的 50 个任务（2026-09-20 用户定的：「只想显示近期的 50 条，不想显示太多」）。上限是这张卡的**定位**（一眼看最近发生了什么），不承担「翻遍历史」——那件事归活动分析页 |
| `FEED_PAGE_SIZE` | `= FEED_LIMIT` | 总览近期活动的默认档位。默认就等于上限，所以平时**只有一页**（一页时不摆翻页箭头）。后续可能**搬到设置里**做成可配项 —— 真搬时改这一处常量 |
| `GANTT_LIMIT` | `20` | 总览焦点时间轴上限（无分页） |
| `URGENCY_LABEL` | `["紧急","重要","关注","普通"]` | 优先级**术语**（P0–P3）。只有名字 —— 展示要带编号用下面的 `urgencyText()` |
| `urgencyText(level)` | `紧急(P0)` | 名字 + 编号。总览的优先级分布、任务页的优先级下拉、任务行悬停小卡 |
| `URGENCY_LEVELS` | `[0, 1, 2, 3]` | 带类型的档位迭代器 —— 遍历时不用写 `as Urgency`（断言的写法，档位数变了不会报错） |
| `STATUS_LABEL` | `{overdue:"逾期", todo:"待办", doing:"进行中", done:"已完成"}` | 状态文案。**下标是 `StatusName`（四档）而不是 `TaskStatus`（三档）** —— 「待办」作为当前状态已删，但活动记录里的 `from: "todo"` 还要照原样显示 |
| `matchesStatus(task, filter)` | — | 状态筛选判定。总览卡 / 任务页 chip / 管理页筛选 / 图谱页过滤，四个消费方共用（「待办」删掉后它就是一对一，不再有合并口径） |
| `AXIS_H`（`overview.ts`） | `150` | 时间轴基准高度，**必须与 CSS 的 `.tdt` 一致** |
| `GANTT_DAYS`（`tasks.ts`） | `32` | 任务页甘特窗口固定天数（默认 `ganttAnchor = TODAY - 16`）。后续可能进设置 |
| `TODAY`（`data/time.ts`） | 真实时钟 | 界面里的「今天」 |
| `DEMO_TODAY`（`data/mock.ts`） | `[9, 12]` | 仅样例数据排布锚点 |

### 8.2 依赖清单

| 包 | 用途 |
|----|------|
| `@tauri-apps/api` | 前端调用宿主能力 |
| `@tauri-apps/plugin-sql` | SQLite |
| `plugin-fs` / `plugin-dialog` / `plugin-opener` | 文件读写、系统弹窗、打开外部链接 |
| `plugin-global-shortcut` / `plugin-autostart` | 全局热键、开机自启 |

Rust 侧另有 `plugin-single-instance`、`plugin-persisted-scope`（**必须排在拥有 scope 的插件之后**）、
Tauri features `tray-icon` / `image-png` / `image-ico`。

**没有任何前端运行时依赖**（无框架、无 UI 库、无 xlsx 库）—— 这条要守住。

### 8.3 已撤的设计（别再照着旧文档实现）

| 撤掉的 | 时间 | 为什么 |
|--------|------|--------|
| 文件级「导出 .md / 导入 .md」 | 2026-09-17 | 导出改成了给人看的清单；导入被批量新建取代（当场看得见解析出几条） |
| 任务管理「导入 .md」 | 2026-09-17 | 同上 |
| 工具行的「快速新建」 | — | 新建统一走页头「＋ 新建任务」，打开的是和编辑同一套表单 |
| 工具行那个只写「批量」的按钮 | 2026-09-18 | 先挪到页头、改名「批量新建任务」，再整体搬到任务管理页、改名「数据迁入」。它更常被用来导一批旧数据，和那页的「导出 ▾」正好一进一出；页头的位置该留给每天都用的「＋ 新建任务」 |
| 抽屉底部的「删除 / 保存」 | — | 改动即时生效，没有「改了没保存」的状态 |
| 托盘菜单的「新建任务」 | 2026-09-17 | 只能把窗口叫出来再弹浮层，不值当 |
| 设置里的时间轴档位 | 2026-09-17 | 工具行是唯一入口（工具行那排本身也在 2026-09-21 撤了） |
| 设置里的「窗口置顶」 | 2026-09-17 | 标题栏图钉是唯一入口 |
| 标题栏的图标与版本号徽章 | — | 装饰；版本号在设置 → 关于 |
| 设置的分页签 | — | 一页到底 |
| 设置 → AI 助手页签 | — | 7 行没有一行接了后端，其中「专用工作区」还指向已删除的目录 |
| `registry.ts` 的 `blocks` / `group` | — | 没有消费方 |
| 循环字段 `+1w` | — | 从来没有行为、界面上也没有编辑入口，只是随 md 文本空转（解析时仍吃掉旧标记） |
