codex-work-receipt 0.7.0 → 0.7.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,6 +1,16 @@
1
1
  # Changelog
2
2
 
3
- ## 0.7.0 - 2026-07-19
3
+ ## 0.7.2
4
+
5
+ - 修复同一 Codex 会话存在多份 append-only 日志修订时生成重复 canonical factId、导致小程序云端入库拒绝的问题。
6
+ - cwr2 manifest 输出前新增 factId 唯一性防线,避免无效二维码进入扫码链路。
7
+ - “最近 N 小时”在交互选择、终端输出和 HTML 中明确标记为私人滚动摘要,并提示其不参与 AI 供销社统计。
8
+
9
+ ## 0.7.1
10
+
11
+ - 修正 HTML 长图导出:PNG 只保留完整小票和微信小程序码,不再包含数据二维码或分片数据码。
12
+
13
+ ## 0.7.0
4
14
 
5
15
  - 新增 Codex 原生桌宠“票仔 · AI 小票工”,提供夜班绿票风格的核心状态与左右移动动画,并支持通过 `--install-pet`、`--uninstall-pet` 和 `--install-companion` 安装管理。
6
16
  - AI 打工小票 Skill 新增“票仔,开今天的票”等自然语言入口。
package/README.en.md CHANGED
@@ -24,6 +24,16 @@ Codex Work Receipt summarizes turns, tool calls, Tokens, duration, and models, t
24
24
 
25
25
  Version `0.6.0` uses the cwr2 protocol to create stable privacy-safe facts for each session and calendar day. Overlapping today, last-seven-days, and this-week receipts can be deduplicated by the receiver, and oversized payloads are automatically split into reorderable multipart QR codes that rotate one at a time in the HTML so the camera never sees multiple data codes at once.
26
26
 
27
+ ## Meet Ticket Buddy
28
+
29
+ <p align="center">
30
+ <a href="docs/codex-pet.en.md"><img src="docs/images/codex-pet-showcase.png" alt="Ticket Buddy in idle, working, needs-input, ready, and failed states" width="920"></a>
31
+ </p>
32
+
33
+ <p align="center">
34
+ <sub>Ticket Buddy changes expression as Codex becomes idle, runs a task, needs input, finishes, or gets blocked. Select the image for setup and state details.</sub>
35
+ </p>
36
+
27
37
  ## Quickstart
28
38
 
29
39
  Requires Node.js 20+ and local Codex session records. No clone required; choose today, the last 3 hours, the last 7 days, this week, or a specific session:
@@ -44,7 +54,9 @@ Summarize the last 3 hours:
44
54
  npx codex-work-receipt@latest --hours 3 --lang en
45
55
  ```
46
56
 
47
- HTML and structured data are written to `./codex-work-receipt-output/` by default. The generated page can save a high-resolution PNG containing the full receipt and QR import stub. Non-interactive options such as `--latest` and `--today` remain available; see the [CLI guide](docs/cli.en.md).
57
+ “Last N hours” is a rolling summary for private history only. It does not participate in AI Work Cooperative deduplicated accounting. Use today, this week, the last seven days, or a specific session when you want accountable canonical facts.
58
+
59
+ HTML and structured data are written to `./codex-work-receipt-output/` by default. The generated page can save a high-resolution PNG containing only the full receipt and WeChat mini-program code; data QR codes are excluded. Non-interactive options such as `--latest` and `--today` remain available; see the [CLI guide](docs/cli.en.md).
48
60
 
49
61
  ## Ask Codex directly
50
62
 
@@ -89,9 +101,9 @@ The desktop page generates one or more privacy-safe data QR codes. The companion
89
101
  ## Sponsor
90
102
 
91
103
  <p align="center">
92
- <a href="https://modelflare.dev/"><img src="docs/images/sponsors/modelflare-logo.png" alt="ModelFlare Logo" width="56"></a><br>
93
- <a href="https://modelflare.dev/"><strong>ModelFlare</strong></a> · <a href="https://modelflare.dev/">modelflare.dev</a><br>
94
- <sub>Helping independent developers access leading AI model APIs reliably and at a very low cost, so they can quickly build their own products with advanced model capabilities.</sub>
104
+ <a href="https://modelflare.dev/sign-up?partner=OB9YXNSEEGOL"><img src="docs/images/sponsors/modelflare-logo.png" alt="ModelFlare Logo" width="56"></a><br>
105
+ <a href="https://modelflare.dev/sign-up?partner=OB9YXNSEEGOL"><strong>ModelFlare</strong></a> · <a href="https://modelflare.dev/sign-up?partner=OB9YXNSEEGOL">modelflare.dev</a><br>
106
+ <sub>Affordable and reliable access to leading AI model APIs, helping independent developers validate ideas faster and turn them into real products.</sub>
95
107
  </p>
96
108
 
97
109
  ## License
package/README.md CHANGED
@@ -24,6 +24,16 @@
24
24
 
25
25
  `0.6.0` 使用 cwr2 协议,按“会话 × 自然日”生成稳定的脱敏事实。今日、近 7 日和本周小票即使范围重叠,接收端也能识别相同工作;数据过大时会自动生成多张可重组的分片二维码,并在 HTML 中逐张自动轮播,避免镜头同时识别多个数据码。
26
26
 
27
+ ## 认识票仔
28
+
29
+ <p align="center">
30
+ <a href="docs/codex-pet.md"><img src="docs/images/codex-pet-showcase.png" alt="票仔 AI 小票工的待机、打工、等待确认、完成和失败状态" width="920"></a>
31
+ </p>
32
+
33
+ <p align="center">
34
+ <sub>票仔会跟随 Codex 的空闲、执行、等待确认、完成和失败状态切换表情。点击图片查看安装与状态说明。</sub>
35
+ </p>
36
+
27
37
  ## Quickstart
28
38
 
29
39
  需要 Node.js 20 或更高版本,并且本机已经使用过 Codex。无需克隆仓库,运行后选择“今天 / 最近 3 小时 / 近 7 日 / 本周 / 指定会话”:
@@ -44,7 +54,9 @@ npx codex-work-receipt@latest --today
44
54
  npx codex-work-receipt@latest --hours 3
45
55
  ```
46
56
 
47
- 网页和结构数据默认保存在 `./codex-work-receipt-output/`。生成的网页支持一键保存包含主小票和二维码导入联的高清长图。也可以继续使用 `--latest`、`--today` 等非交互参数,详见 [CLI 使用文档](docs/cli.md)。
57
+ “最近 N 小时”是滚动摘要,只用于查看和保存私人历史,不参与 AI 供销社的去重统计。需要进入供销社时,请生成“今日 / 本周 / 近 7 日 / 指定会话”小票。
58
+
59
+ 网页和结构数据默认保存在 `./codex-work-receipt-output/`。生成的网页支持一键保存只包含完整小票和微信小程序码的高清长图,数据二维码不会进入图片。也可以继续使用 `--latest`、`--today` 等非交互参数,详见 [CLI 使用文档](docs/cli.md)。
48
60
 
49
61
  ## 直接跟 Codex 说
50
62
 
@@ -89,9 +101,9 @@ Codex 会选择统计范围和主题、执行命令并打开小票。详见 [Cod
89
101
  ## 赞助商
90
102
 
91
103
  <p align="center">
92
- <a href="https://modelflare.dev/"><img src="docs/images/sponsors/modelflare-logo.png" alt="ModelFlare Logo" width="56"></a><br>
93
- <a href="https://modelflare.dev/"><strong>ModelFlare</strong></a> · <a href="https://modelflare.dev/">modelflare.dev</a><br>
94
- <sub>让个人开发者也可以以很低的成本、稳定地接入前沿 AI 模型的 API,借助模型能力快速构建自己的产品。</sub>
104
+ <a href="https://modelflare.dev/sign-up?partner=OB9YXNSEEGOL"><img src="docs/images/sponsors/modelflare-logo.png" alt="ModelFlare Logo" width="56"></a><br>
105
+ <a href="https://modelflare.dev/sign-up?partner=OB9YXNSEEGOL"><strong>ModelFlare</strong></a> · <a href="https://modelflare.dev/sign-up?partner=OB9YXNSEEGOL">modelflare.dev</a><br>
106
+ <sub>为个人开发者提供价格友好、稳定便捷的前沿 AI 模型 API 接入,帮助你更快完成原型验证,把想法变成真正可用的产品。</sub>
95
107
  </p>
96
108
 
97
109
  ## License
package/docs/cli.en.md CHANGED
@@ -39,7 +39,7 @@ Summarize the last 3 hours:
39
39
  npx codex-work-receipt@latest --hours 3 --lang en
40
40
  ```
41
41
 
42
- `--hours` accepts an integer from 1 to 168 and filters exact timestamps, including windows that cross midnight. Rolling-hour receipts use the compatible cwr1 summary protocol and do not create cwr2 session-day canonical facts.
42
+ `--hours` accepts an integer from 1 to 168 and filters exact timestamps, including windows that cross midnight. Rolling-hour receipts use the compatible cwr1 summary protocol, stay in private history, and do not participate in AI Work Cooperative accounting. Use today, this week, the last seven days, or a specific session for cwr2 session-day canonical facts.
43
43
 
44
44
  Last 7 calendar days, including today:
45
45
 
@@ -87,7 +87,7 @@ Default output:
87
87
 
88
88
  Default filenames include the calendar range. Latest-session and selected-session receipts include a short identifier so receipts from different dates or sessions do not overwrite one another.
89
89
 
90
- Open the generated HTML and click “Save full PNG” to download a high-resolution image. It includes the main receipt, mini-program code, and data QR import stub in the currently selected theme. Theme controls, the page background, and the footer note are excluded. With one data QR, the mini-program code and data code remain side by side. When cwr2 exceeds the safe capacity of one QR code, the page shows the mini-program code first and then rotates multipart data codes one at a time without desktop clicks. The full PNG still includes every numbered part so the offline payload remains complete.
90
+ Open the generated HTML and click “Save full PNG” to download a high-resolution image. It contains only the complete main receipt and WeChat mini-program code in the currently selected theme. Data QR codes, multipart data codes, theme controls, the page background, and the footer note are excluded. The HTML page still presents data codes normally: a single code stays beside the mini-program code, while oversized cwr2 payloads rotate their parts after the mini program has been opened so they can be imported on the phone.
91
91
 
92
92
  Set a timezone and output path:
93
93
 
package/docs/cli.md CHANGED
@@ -39,7 +39,7 @@ npx codex-work-receipt@latest --today
39
39
  npx codex-work-receipt@latest --hours 3
40
40
  ```
41
41
 
42
- `--hours` 支持 1~168 的整数,并按精确时间戳筛选,可以跨越午夜。滚动小时小票使用兼容的 cwr1 摘要协议,不会写入 cwr2 的“会话 × 自然日”规范事实。
42
+ `--hours` 支持 1~168 的整数,并按精确时间戳筛选,可以跨越午夜。滚动小时小票使用兼容的 cwr1 摘要协议,不会写入 cwr2 的“会话 × 自然日”规范事实,只保存到私人历史,不参与 AI 供销社统计。需要进入供销社时,请生成“今日 / 本周 / 近 7 日 / 指定会话”小票。
43
43
 
44
44
  最近 7 个自然日(含今天):
45
45
 
@@ -87,7 +87,7 @@ npx codex-work-receipt@latest --latest --theme diner
87
87
 
88
88
  默认文件名会携带统计日期范围;最近会话和指定会话则携带短编号,避免不同日期或会话的小票互相覆盖。
89
89
 
90
- 打开生成的 HTML 后,点击“保存完整长图”即可下载高清 PNG。图片包含主小票、小程序码和数据二维码导入联,并自动使用当前选择的主题;主题按钮、网页背景和底部说明不会进入图片。只有一个数据二维码时,小程序码与数据码继续并排展示。cwr2 数据超过单码安全容量时,网页先单独展示小程序码,随后自动逐张轮播分片数据码;用户无需在电脑上切换上一张或下一张,但仍需在小程序中收齐全部分片。完整长图为保留离线数据,会继续包含全部带序号的分片二维码。
90
+ 打开生成的 HTML 后,点击“保存完整长图”即可下载高清 PNG。图片只包含完整主小票和微信小程序码,并自动使用当前选择的主题;数据二维码、分片数据码、主题按钮、网页背景和底部说明都不会进入图片。网页本身仍会正常展示数据二维码:单码时与小程序码并排,cwr2 超出单码安全容量时则在打开小程序后自动逐张轮播分片数据码,供用户在小程序中完成导入。
91
91
 
92
92
  指定时区和输出路径:
93
93
 
package/docs/codex-pet.md CHANGED
@@ -30,6 +30,8 @@ npx codex-work-receipt@latest --install-companion
30
30
 
31
31
  “最近几个小时”默认表示最近 3 小时。
32
32
 
33
+ 最近 N 小时小票是私人滚动摘要,不进入 AI 供销社;需要参与供销社统计时,请让票仔生成今日、本周、近七日或指定会话小票。
34
+
33
35
  ## 只安装或卸载桌宠
34
36
 
35
37
  只安装票仔:
@@ -38,6 +38,8 @@ Skill 会安装到:
38
38
 
39
39
  Codex 会根据表达选择单次会话、最近 1~168 小时、今天、近七日或本周,以及语言和主题,然后执行命令并打开生成的网页。“最近几个小时”默认统计最近 3 小时。
40
40
 
41
+ “最近 N 小时”生成的是只用于私人历史的滚动摘要,不进入 AI 供销社;如果用户明确希望参与供销社统计,应改用今日、本周、近七日或指定会话范围。
42
+
41
43
  如果当前 Codex 任务仍在运行,而你希望立刻查看小票,建议新开一个本地 Codex 会话再说“开今天的票”;在当前运行会话中发送的消息可能根据 Follow-up behavior 被用于 Steer 或 Queue。
42
44
 
43
45
  也可以通过 `$ai-work-receipt` 显式调用这个 Skill。
@@ -32,7 +32,9 @@ cwr2p.<transferId>.<partIndex>.<partCount>.<totalChecksum>.<partChecksum>.<chunk
32
32
 
33
33
  The mini program validates the prefix and checksum before decompressing and parsing the payload. Future schema versions use the compact `v` field for compatibility.
34
34
 
35
- Compact field `o` explicitly carries `latest`, `session`, `last-hours`, `today`, `last-7-days`, or `this-week`. `d[3]` and `d[4]` carry date boundaries. Today, last-seven-days, and this-week receipts continue to use cwr2 canonical facts. `last-hours` is a rolling window, so it uses the compatible cwr1 summary payload to avoid colliding with session-day fact identities. The updated mini program can still import older QR codes.
35
+ Compact field `o` explicitly carries `latest`, `session`, `last-hours`, `today`, `last-7-days`, or `this-week`. `d[3]` and `d[4]` carry date boundaries. Today, last-seven-days, and this-week receipts continue to use cwr2 canonical facts. `last-hours` is a rolling window, so it uses the compatible cwr1 summary payload to avoid colliding with session-day fact identities; it stays in private history and does not participate in AI Work Cooperative accounting. The updated mini program can still import older QR codes.
36
+
37
+ When the same Codex session has multiple append-only log revisions, the generator keeps the more complete revision. Fact IDs in a cwr2 manifest must be unique; an identity collision stops QR generation instead of emitting an invalid payload.
36
38
 
37
39
  `presentation.compensation` contains playful AI work points, not real API cost. For compatibility with the current Chinese mini program, QR display copy remains Chinese while compact fields `l` and `r` carry the desktop locale and language-neutral role ID. English HTML and local JSON remain fully localized.
38
40
 
@@ -32,7 +32,9 @@ cwr2p.<transferId>.<partIndex>.<partCount>.<totalChecksum>.<partChecksum>.<chunk
32
32
 
33
33
  小程序应检查前缀和校验值,再解压并解析数据。未来结构升级通过 `v` 字段兼容。
34
34
 
35
- 精简字段 `o` 显式携带 `latest`、`session`、`last-hours`、`today`、`last-7-days` 或 `this-week`;`d[3]` 和 `d[4]` 携带日期边界。今日、近 7 日和本周继续使用 cwr2 canonical facts。`last-hours` 是滚动时间窗,为避免与“会话 × 自然日”事实发生身份冲突,使用兼容的 cwr1 摘要载荷。旧二维码仍可由新版小程序导入。
35
+ 精简字段 `o` 显式携带 `latest`、`session`、`last-hours`、`today`、`last-7-days` 或 `this-week`;`d[3]` 和 `d[4]` 携带日期边界。今日、近 7 日和本周继续使用 cwr2 canonical facts。`last-hours` 是滚动时间窗,为避免与“会话 × 自然日”事实发生身份冲突,使用兼容的 cwr1 摘要载荷,只进入私人历史、不参与 AI 供销社统计。旧二维码仍可由新版小程序导入。
36
+
37
+ 同一 Codex 会话出现多份 append-only 日志修订时,生成器只采用更完整的修订;cwr2 manifest 中的 factId 必须唯一,检测到身份冲突时不会输出可扫码二维码。
36
38
 
37
39
  `presentation.compensation` 是娱乐化 AI 工分,不代表真实 API 费用。为兼容当前中文小程序,二维码展示文案继续使用中文,并通过精简字段 `l` 和 `r` 携带桌面语言及工种语义 ID;英文 HTML 和本地 JSON 不受影响。
38
40
 
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codex-work-receipt",
3
- "version": "0.7.0",
3
+ "version": "0.7.2",
4
4
  "type": "module",
5
5
  "description": "Generate a privacy-first Codex work receipt from local session metadata.",
6
6
  "author": "a-bai-2026",
@@ -13,6 +13,7 @@ Use the published `codex-work-receipt` CLI as the only statistics and rendering
13
13
  - Use `--hours <N>` for “最近 N 小时”“过去 N 小时” or “近 N 小时”. Accept integers from 1 to 168.
14
14
  - Use `--hours 3` for an unspecified “最近几个小时”“过去几个小时” request.
15
15
  - Use `--hours 12` for “最近半天”.
16
+ - Treat `--hours` receipts as rolling summaries for private history only. They do not participate in AI Work Cooperative accounting; if the user asks for an accountable cooperative receipt, use today, this week, the last seven days, or a specific session instead.
16
17
  - Use `--today` for “今天”“今日”“今天全部工作” or a daily summary.
17
18
  - Use `--range last-7-days` for “最近七天”“近 7 日” or a rolling seven-calendar-day summary.
18
19
  - Use `--range this-week` for “本周”“这周” or a Monday-to-now summary.
@@ -39,6 +40,7 @@ Replace only the mode and optional flags according to the request.
39
40
 
40
41
  3. Let the CLI open the generated HTML unless the user requested `--no-open`.
41
42
  4. On success, report the HTML path and tell the user to scan the official mini-program code first, then the adjacent data QR code.
43
+ 5. When `--hours` was used, explicitly state that the receipt is a private rolling summary and will not enter AI Work Cooperative statistics.
42
44
 
43
45
  ## Privacy and failures
44
46
 
package/src/cli.mjs CHANGED
@@ -9,7 +9,7 @@ import QRCode from "qrcode";
9
9
  import { parseArgs, printHelp } from "./core/args.mjs";
10
10
  import { buildCanonicalFacts } from "./core/fact-buckets.mjs";
11
11
  import { collectMetrics } from "./core/metrics.mjs";
12
- import { getScopeLabel } from "./core/presentation.mjs";
12
+ import { getRollingSummaryNotice, getScopeLabel } from "./core/presentation.mjs";
13
13
  import { encodeReceiptPayloads } from "./core/qr-payload.mjs";
14
14
  import { outputSlugForRange, resolveRange } from "./core/range.mjs";
15
15
  import { buildReceiptRecord, persistReceiptRecord } from "./core/receipt-record.mjs";
@@ -145,6 +145,9 @@ async function main() {
145
145
  console.log(record.manifest
146
146
  ? `Data QR: ${qrPayloads.length} code(s) · ${record.manifest.fact_count} canonical fact(s) · schema v${record.schema_version}`
147
147
  : `Data QR: ${qrPayloads.length} code(s) · rolling summary · schema v${record.schema_version}`);
148
+ if (record.source.scope === "last-hours") {
149
+ console.log(`Note: ${getRollingSummaryNotice(options.locale, record.source.hours)}`);
150
+ }
148
151
  if (!miniProgramCodeDataUrl) console.log("Mini-program code: not configured; using the explicit placeholder");
149
152
  } else {
150
153
  console.log(`已生成网页:${outputFile}`);
@@ -155,6 +158,9 @@ async function main() {
155
158
  console.log(record.manifest
156
159
  ? `数据二维码:${qrPayloads.length} 个 · ${record.manifest.fact_count} 条规范事实 · schema v${record.schema_version}`
157
160
  : `数据二维码:${qrPayloads.length} 个 · 滚动摘要 · schema v${record.schema_version}`);
161
+ if (record.source.scope === "last-hours") {
162
+ console.log(`提示:${getRollingSummaryNotice(options.locale, record.source.hours)}`);
163
+ }
158
164
  if (!miniProgramCodeDataUrl) console.log("小程序码:尚未配置,页面使用明确占位符");
159
165
  }
160
166
  if (options.open) openFile(outputFile);
@@ -198,6 +198,11 @@ export function buildCanonicalFacts(sessions, range, options = {}) {
198
198
  left.local_date.localeCompare(right.local_date) || left.fact_id.localeCompare(right.fact_id)
199
199
  ));
200
200
 
201
+ const uniqueFactIds = new Set(facts.map((fact) => fact.fact_id));
202
+ if (uniqueFactIds.size !== facts.length) {
203
+ throw new Error("检测到重复的规范事实,请更新生成器后重新生成小票");
204
+ }
205
+
201
206
  return {
202
207
  facts,
203
208
  coverage: coverageForRange(range, sessions.scanMode || options.scanMode || "best_effort", observedAt),
@@ -142,10 +142,12 @@ const RECEIPT_COPY = {
142
142
  multipartRestart: "重新显示小程序码",
143
143
  dataQrAlt: "当前小票数据二维码",
144
144
  miniProgramAlt: "微信小程序码",
145
+ exportMiniProgramLabel: "微信扫码打开小程序",
145
146
  placeholderLabel: "小程序码",
146
147
  placeholderValue: "待接入",
147
148
  placeholderAria: "小程序码待接入",
148
149
  transferNote: "数据码只包含时间、轮次、Token和工具调用等统计,不包含Prompt、回复正文、代码、项目路径或文件名。",
150
+ rollingSummaryNotice: "最近 {hours} 小时属于滚动摘要,只保存到私人历史,不参与 AI 供销社统计。需要统计时请生成“今日 / 本周 / 近 7 日 / 指定会话”小票。",
149
151
  privacy: "结构数据同时保存在本机,未来可以批量导入小程序历史记录。",
150
152
  },
151
153
  en: {
@@ -215,10 +217,12 @@ const RECEIPT_COPY = {
215
217
  multipartRestart: "Show mini-program code again",
216
218
  dataQrAlt: "Current receipt data QR code",
217
219
  miniProgramAlt: "WeChat mini-program code",
220
+ exportMiniProgramLabel: "Scan to open the mini program",
218
221
  placeholderLabel: "Mini program",
219
222
  placeholderValue: "Pending",
220
223
  placeholderAria: "Mini-program code pending",
221
224
  transferNote: "The data code contains only statistics such as time, turns, Tokens, and tool calls. It does not contain prompts, responses, code, project paths, or file names.",
225
+ rollingSummaryNotice: "The last {hours} hours is a rolling summary for private history only. It does not participate in AI Work Cooperative accounting. Use today, this week, the last seven days, or a specific session for accountable facts.",
222
226
  privacy: "Structured data is also stored locally for future batch import into your mini-program history.",
223
227
  },
224
228
  };
@@ -271,6 +275,10 @@ export function getScopeLabel(scope, locale = DEFAULT_LOCALE, hours = null) {
271
275
  return String(template).replaceAll("{hours}", String(hours || 3));
272
276
  }
273
277
 
278
+ export function getRollingSummaryNotice(locale = DEFAULT_LOCALE, hours = null) {
279
+ return String(getReceiptCopy(locale).rollingSummaryNotice).replaceAll("{hours}", String(hours || 3));
280
+ }
281
+
274
282
  export function buildCompensation(scope, amount, locale = DEFAULT_LOCALE) {
275
283
  const copy = COMPENSATION_COPY[SUPPORTED_LOCALES.has(locale) ? locale : DEFAULT_LOCALE];
276
284
  return {
@@ -37,7 +37,9 @@ export async function promptForRange({ locale, timezone, loadRecentSessions }) {
37
37
  try {
38
38
  console.log(isEnglish ? "\nChoose a receipt range:\n" : "\n请选择小票统计范围:\n");
39
39
  console.log(isEnglish ? "1. All activity today (recommended)" : "1. 今天全部活动(推荐)");
40
- console.log(isEnglish ? "2. Last 3 hours" : "2. 最近 3 小时");
40
+ console.log(isEnglish
41
+ ? "2. Last 3 hours (private rolling summary; not counted by the cooperative)"
42
+ : "2. 最近 3 小时(私人滚动摘要,不计入供销社)");
41
43
  console.log(isEnglish ? "3. Last 7 calendar days" : "3. 最近 7 个自然日");
42
44
  console.log(isEnglish ? "4. This week (Monday to now)" : "4. 本周(周一至今)");
43
45
  console.log(isEnglish ? "5. Choose a specific session" : "5. 选择一个具体会话");
@@ -69,6 +69,38 @@ function calendarCandidates(files, startDate) {
69
69
  return files.filter((file) => file.modifiedAt >= approximateStart);
70
70
  }
71
71
 
72
+ function revisionDominates(candidate, current) {
73
+ const candidateRevision = candidate.sourceRevision || {};
74
+ const currentRevision = current.sourceRevision || {};
75
+ const candidateRows = Number(candidateRevision.row_count || 0);
76
+ const currentRows = Number(currentRevision.row_count || 0);
77
+ const candidateBytes = Number(candidateRevision.byte_length || 0);
78
+ const currentBytes = Number(currentRevision.byte_length || 0);
79
+ return candidateRows >= currentRows && candidateBytes >= currentBytes
80
+ && (candidateRows > currentRows || candidateBytes > currentBytes);
81
+ }
82
+
83
+ function preferSession(candidate, current) {
84
+ if (revisionDominates(candidate, current)) return true;
85
+ if (revisionDominates(current, candidate)) return false;
86
+
87
+ const candidateModifiedAt = Number(candidate.modifiedAt || candidate.endAt || 0);
88
+ const currentModifiedAt = Number(current.modifiedAt || current.endAt || 0);
89
+ if (candidateModifiedAt !== currentModifiedAt) return candidateModifiedAt > currentModifiedAt;
90
+
91
+ return String(candidate.filePath || "").localeCompare(String(current.filePath || "")) > 0;
92
+ }
93
+
94
+ export function deduplicateCodexSessions(sessions) {
95
+ const selected = new Map();
96
+ for (const session of sessions) {
97
+ const key = `${session.identityQuality || "metadata"}:${session.sessionId}`;
98
+ const current = selected.get(key);
99
+ if (!current || preferSession(session, current)) selected.set(key, session);
100
+ }
101
+ return [...selected.values()];
102
+ }
103
+
72
104
  export function loadCodexSessions(range) {
73
105
  const files = codexSessionFiles();
74
106
  let candidates = files;
@@ -85,8 +117,7 @@ export function loadCodexSessions(range) {
85
117
  scanMode = fullScan ? "full" : "best_effort";
86
118
  }
87
119
 
88
- const sessions = candidates
89
- .map(sessionFromFile)
120
+ const sessions = deduplicateCodexSessions(candidates.map(sessionFromFile))
90
121
  .sort((left, right) => right.endAt - left.endAt);
91
122
 
92
123
  if (range.scope === "latest") {
@@ -106,10 +137,10 @@ export function loadCodexSessions(range) {
106
137
  }
107
138
 
108
139
  export function listRecentCodexSessions(limit = 10) {
109
- const sessions = codexSessionFiles()
140
+ const sessions = deduplicateCodexSessions(codexSessionFiles()
110
141
  .slice(0, Math.max(40, limit * 3))
111
142
  .map(sessionFromFile)
112
- .filter((session) => session.rows.length)
143
+ .filter((session) => session.rows.length))
113
144
  .sort((left, right) => right.endAt - left.endAt)
114
145
  .slice(0, limit);
115
146
 
@@ -7,6 +7,7 @@ import {
7
7
  buildCompensation,
8
8
  DEFAULT_LOCALE,
9
9
  getReceiptCopy,
10
+ getRollingSummaryNotice,
10
11
  getScopeLabel,
11
12
  } from "../core/presentation.mjs";
12
13
 
@@ -71,6 +72,9 @@ export function renderHtml({ record, dataQrDataUrl = null, dataQrDataUrls = null
71
72
  const isCalendarScope = new Set(["today", "last-7-days", "this-week"]).has(record.source.scope);
72
73
  const spansMultipleDates = rangeStartDate !== rangeEndDate;
73
74
  const scopeLabel = getScopeLabel(record.source.scope, locale, record.source.hours);
75
+ const rollingSummaryNotice = record.source.scope === "last-hours"
76
+ ? getRollingSummaryNotice(locale, record.source.hours)
77
+ : null;
74
78
  const dateRange = spansMultipleDates
75
79
  ? `${formatDateKey(rangeStartDate, locale)}—${formatDateKey(rangeEndDate, locale)}`
76
80
  : formatDateKey(rangeEndDate, locale);
@@ -91,6 +95,7 @@ export function renderHtml({ record, dataQrDataUrl = null, dataQrDataUrls = null
91
95
  busyLabel: copy.exportingImage,
92
96
  successLabel: copy.exportSuccess,
93
97
  errorLabel: copy.exportError,
98
+ miniProgramLabel: copy.exportMiniProgramLabel,
94
99
  fileBase: `codex-work-receipt-${record.source.scope}-${spansMultipleDates ? `${rangeStartDate}-to-${rangeEndDate}` : `${rangeEndDate}-${record.id.slice(4, 12)}`}`,
95
100
  }).replaceAll("<", "\\u003c");
96
101
  const rows = [
@@ -151,7 +156,7 @@ export function renderHtml({ record, dataQrDataUrl = null, dataQrDataUrls = null
151
156
  <div class="qr-grid qr-grid--export-only" hidden>
152
157
  <div class="qr-item">
153
158
  <div class="qr-frame">${miniProgramVisual}</div>
154
- <strong>${escapeHtml(copy.openMiniProgram)}</strong>
159
+ <strong data-export-mini-label>${escapeHtml(copy.openMiniProgram)}</strong>
155
160
  <span>${escapeHtml(copy.openMiniProgramHint)}</span>
156
161
  </div>
157
162
  ${dataQrItems}
@@ -160,7 +165,7 @@ export function renderHtml({ record, dataQrDataUrl = null, dataQrDataUrls = null
160
165
  <div class="qr-grid qr-grid--single">
161
166
  <div class="qr-item">
162
167
  <div class="qr-frame">${miniProgramVisual}</div>
163
- <strong>${escapeHtml(copy.openMiniProgram)}</strong>
168
+ <strong data-export-mini-label>${escapeHtml(copy.openMiniProgram)}</strong>
164
169
  <span>${escapeHtml(copy.openMiniProgramHint)}</span>
165
170
  </div>
166
171
  ${dataQrItems}
@@ -492,6 +497,13 @@ export function renderHtml({ record, dataQrDataUrl = null, dataQrDataUrls = null
492
497
  line-height: 1.65;
493
498
  text-align: center;
494
499
  }
500
+ .transfer-note--warning {
501
+ padding: 12px 14px;
502
+ border: 1px solid color-mix(in srgb, var(--ink) 28%, transparent);
503
+ background: color-mix(in srgb, var(--accent) 76%, var(--paper));
504
+ color: var(--ink);
505
+ font-weight: 700;
506
+ }
495
507
  .privacy {
496
508
  margin: 24px auto 0;
497
509
  max-width: 440px;
@@ -568,6 +580,7 @@ export function renderHtml({ record, dataQrDataUrl = null, dataQrDataUrls = null
568
580
  <p>${escapeHtml(copy.transferDescription)}</p>
569
581
  </header>
570
582
  ${transferVisual}
583
+ ${rollingSummaryNotice ? `<p class="transfer-note transfer-note--warning">${escapeHtml(rollingSummaryNotice)}</p>` : ""}
571
584
  <p class="transfer-note">${escapeHtml(copy.transferNote)}</p>
572
585
  </section>
573
586
  </div>
@@ -692,6 +705,50 @@ export function renderHtml({ record, dataQrDataUrl = null, dataQrDataUrls = null
692
705
  }));
693
706
  }
694
707
 
708
+ function sanitizeExportNode(node) {
709
+ const multipartLiveNode = node.querySelector(".multipart-live");
710
+ if (multipartLiveNode) multipartLiveNode.remove();
711
+
712
+ const exportGridNode = node.querySelector(".qr-grid--export-only");
713
+ if (exportGridNode) {
714
+ exportGridNode.removeAttribute("hidden");
715
+ exportGridNode.style.display = "grid";
716
+ }
717
+
718
+ node.querySelectorAll("[data-data-qr-index]").forEach((item) => item.remove());
719
+ node.querySelectorAll(".qr-grid").forEach((grid) => {
720
+ grid.style.gridTemplateColumns = "minmax(0, 1fr)";
721
+ grid.style.justifyItems = "center";
722
+ });
723
+
724
+ const miniProgramLabel = node.querySelector("[data-export-mini-label]");
725
+ if (miniProgramLabel) miniProgramLabel.textContent = exportConfig.miniProgramLabel;
726
+ const transferDescription = node.querySelector(".transfer-heading p");
727
+ if (transferDescription) transferDescription.remove();
728
+ const transferNote = node.querySelector(".transfer-note");
729
+ if (transferNote) transferNote.remove();
730
+
731
+ node.querySelectorAll(".paper").forEach((paper) => {
732
+ paper.style.boxShadow = "none";
733
+ });
734
+ }
735
+
736
+ function createExportNode(source, paperColor) {
737
+ const clone = source.cloneNode(true);
738
+ sanitizeExportNode(clone);
739
+ const sourceWidth = Math.ceil(source.getBoundingClientRect().width || source.scrollWidth);
740
+ Object.assign(clone.style, {
741
+ position: "absolute",
742
+ left: "-100000px",
743
+ top: "0",
744
+ width: sourceWidth + "px",
745
+ background: paperColor,
746
+ pointerEvents: "none",
747
+ });
748
+ document.body.appendChild(clone);
749
+ return clone;
750
+ }
751
+
695
752
  function downloadImage(dataUrl, filename) {
696
753
  const link = document.createElement("a");
697
754
  link.href = dataUrl;
@@ -708,15 +765,17 @@ export function renderHtml({ record, dataQrDataUrl = null, dataQrDataUrls = null
708
765
  exportButton.disabled = true;
709
766
  exportButton.textContent = exportConfig.busyLabel;
710
767
  setExportStatus("");
768
+ let renderNode = null;
711
769
 
712
770
  try {
713
771
  if (document.fonts?.ready) await document.fonts.ready;
714
- await waitForImages(exportNode);
715
- const width = Math.ceil(exportNode.scrollWidth);
716
- const height = Math.ceil(exportNode.scrollHeight);
717
- const scale = Math.max(2, Math.min(3, 1500 / Math.max(1, width)));
718
772
  const paperColor = getComputedStyle(document.documentElement).getPropertyValue("--paper").trim() || "#ffffff";
719
- const dataUrl = await domtoimage.toPng(exportNode, {
773
+ renderNode = createExportNode(exportNode, paperColor);
774
+ await waitForImages(renderNode);
775
+ const width = Math.ceil(renderNode.scrollWidth);
776
+ const height = Math.ceil(renderNode.scrollHeight);
777
+ const scale = Math.max(2, Math.min(3, 1500 / Math.max(1, width)));
778
+ const dataUrl = await domtoimage.toPng(renderNode, {
720
779
  width,
721
780
  height,
722
781
  scale,
@@ -725,17 +784,12 @@ export function renderHtml({ record, dataQrDataUrl = null, dataQrDataUrls = null
725
784
  cacheBust: false,
726
785
  style: { background: paperColor },
727
786
  onclone(clone) {
787
+ sanitizeExportNode(clone);
788
+ clone.style.position = "static";
789
+ clone.style.left = "auto";
790
+ clone.style.top = "auto";
791
+ clone.style.width = width + "px";
728
792
  clone.style.background = paperColor;
729
- const multipartLiveClone = clone.querySelector(".multipart-live");
730
- if (multipartLiveClone) multipartLiveClone.remove();
731
- const exportGridClone = clone.querySelector(".qr-grid--export-only");
732
- if (exportGridClone) {
733
- exportGridClone.removeAttribute("hidden");
734
- exportGridClone.style.display = "grid";
735
- }
736
- clone.querySelectorAll(".paper").forEach((paper) => {
737
- paper.style.boxShadow = "none";
738
- });
739
793
  },
740
794
  logger: {},
741
795
  });
@@ -748,6 +802,7 @@ export function renderHtml({ record, dataQrDataUrl = null, dataQrDataUrls = null
748
802
  console.error(error);
749
803
  setExportStatus(exportConfig.errorLabel, "error");
750
804
  } finally {
805
+ if (renderNode) renderNode.remove();
751
806
  exportButton.disabled = false;
752
807
  exportButton.textContent = exportConfig.idleLabel;
753
808
  }