codex-work-receipt 0.5.0 → 0.6.1
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 +15 -0
- package/README.en.md +3 -1
- package/README.md +3 -1
- package/docs/cli.en.md +1 -1
- package/docs/cli.md +1 -1
- package/docs/data-schema.en.md +4 -0
- package/docs/data-schema.md +4 -0
- package/docs/mobile-import.en.md +8 -6
- package/docs/mobile-import.md +8 -6
- package/docs/privacy.en.md +2 -0
- package/docs/privacy.md +2 -0
- package/package.json +1 -1
- package/src/cli.mjs +11 -8
- package/src/core/barcode.mjs +52 -0
- package/src/core/canonical.mjs +27 -0
- package/src/core/fact-buckets.mjs +205 -0
- package/src/core/fact-identity.mjs +39 -0
- package/src/core/metrics.mjs +14 -20
- package/src/core/presentation.mjs +12 -0
- package/src/core/qr-payload.mjs +208 -19
- package/src/core/receipt-record.mjs +37 -11
- package/src/core/source-revision.mjs +17 -0
- package/src/parsers/codex.mjs +27 -7
- package/src/renderers/html.mjs +223 -19
package/src/renderers/html.mjs
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import { createRequire } from "node:module";
|
|
3
3
|
|
|
4
|
+
import { buildCode128B } from "../core/barcode.mjs";
|
|
4
5
|
import { formatDate, formatDuration, formatNumber, formatTime } from "../lib/time.mjs";
|
|
5
6
|
import { buildCompensation, DEFAULT_LOCALE, getReceiptCopy } from "../core/presentation.mjs";
|
|
6
7
|
|
|
@@ -24,6 +25,16 @@ function receiptRow(label, value, emphasize = false) {
|
|
|
24
25
|
return `<div class="receipt-row${emphasize ? " receipt-row--emphasize" : ""}"><span>${escapeHtml(label)}</span><strong>${escapeHtml(value)}</strong></div>`;
|
|
25
26
|
}
|
|
26
27
|
|
|
28
|
+
function receiptBarcode(value) {
|
|
29
|
+
const barcode = buildCode128B(value);
|
|
30
|
+
const segments = barcode.segments.map((segment) => {
|
|
31
|
+
const kind = segment.isBar ? "bar" : "space";
|
|
32
|
+
return `<span class="barcode-segment barcode-segment--${kind}" style="flex-grow:${segment.width}"></span>`;
|
|
33
|
+
}).join("");
|
|
34
|
+
|
|
35
|
+
return `<div class="barcode" data-barcode-value="${escapeHtml(barcode.value)}" aria-hidden="true">${segments}</div>`;
|
|
36
|
+
}
|
|
37
|
+
|
|
27
38
|
function formatCount(value, units, locale) {
|
|
28
39
|
const amount = Math.max(0, Math.round(value || 0));
|
|
29
40
|
const unit = units[amount === 1 ? 0 : 1];
|
|
@@ -36,7 +47,14 @@ function formatDateKey(value, locale) {
|
|
|
36
47
|
return locale === "en" ? `${match[2]}/${match[3]}/${match[1]}` : `${match[1]}/${match[2]}/${match[3]}`;
|
|
37
48
|
}
|
|
38
49
|
|
|
39
|
-
|
|
50
|
+
function formatCopy(template, values) {
|
|
51
|
+
return Object.entries(values).reduce(
|
|
52
|
+
(result, [key, value]) => result.replaceAll(`{${key}}`, String(value)),
|
|
53
|
+
String(template),
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function renderHtml({ record, dataQrDataUrl = null, dataQrDataUrls = null, miniProgramCodeDataUrl = null }) {
|
|
40
58
|
const locale = record.locale || DEFAULT_LOCALE;
|
|
41
59
|
const copy = getReceiptCopy(locale);
|
|
42
60
|
const startAt = new Date(record.period.start_at);
|
|
@@ -56,7 +74,8 @@ export function renderHtml({ record, dataQrDataUrl, miniProgramCodeDataUrl = nul
|
|
|
56
74
|
: `${formatTime(startAt, timezone, locale)}—${formatTime(endAt, timezone, locale)}`;
|
|
57
75
|
const businessPeriodLabel = isCalendarScope ? copy.meta.period : copy.meta.hours;
|
|
58
76
|
const modelLabel = record.stats.models.length ? record.stats.models.join(" / ") : copy.modelMissing;
|
|
59
|
-
const
|
|
77
|
+
const shortReceiptId = record.id.replace(/^cwr2?_/, "").slice(0, 8).toUpperCase() || "UNKNOWN";
|
|
78
|
+
const receiptNumber = `${shortReceiptId}-${String(record.stats.completed_turns).padStart(3, "0")}`;
|
|
60
79
|
const compensation = record.presentation.compensation || buildCompensation(record.source.scope, 0, locale);
|
|
61
80
|
const responseSeconds = new Intl.NumberFormat(locale === "en" ? "en-US" : "zh-CN", {
|
|
62
81
|
minimumFractionDigits: 1,
|
|
@@ -85,6 +104,62 @@ export function renderHtml({ record, dataQrDataUrl, miniProgramCodeDataUrl = nul
|
|
|
85
104
|
const miniProgramVisual = miniProgramCodeDataUrl
|
|
86
105
|
? `<img src="${miniProgramCodeDataUrl}" alt="${escapeHtml(copy.miniProgramAlt)}">`
|
|
87
106
|
: `<div class="mini-placeholder" role="img" aria-label="${escapeHtml(copy.placeholderAria)}"><span>${escapeHtml(copy.placeholderLabel)}</span><strong>${escapeHtml(copy.placeholderValue)}</strong></div>`;
|
|
107
|
+
const qrUrls = Array.isArray(dataQrDataUrls) && dataQrDataUrls.length
|
|
108
|
+
? dataQrDataUrls
|
|
109
|
+
: dataQrDataUrl
|
|
110
|
+
? [dataQrDataUrl]
|
|
111
|
+
: [];
|
|
112
|
+
const dataQrItems = qrUrls.map((url, index) => `
|
|
113
|
+
<div class="qr-item" data-data-qr-index="${index}">
|
|
114
|
+
<div class="qr-frame"><img src="${url}" alt="${escapeHtml(copy.dataQrAlt)} ${index + 1}/${qrUrls.length}"></div>
|
|
115
|
+
<strong>${escapeHtml(copy.importData)}${qrUrls.length > 1 ? ` ${index + 1}/${qrUrls.length}` : ""}</strong>
|
|
116
|
+
<span>${escapeHtml(copy.importDataHint)}</span>
|
|
117
|
+
</div>`).join("");
|
|
118
|
+
const isMultipart = qrUrls.length > 1;
|
|
119
|
+
const multipartSetupSeconds = 10;
|
|
120
|
+
const multipartConfig = JSON.stringify({
|
|
121
|
+
enabled: isMultipart,
|
|
122
|
+
setupSeconds: multipartSetupSeconds,
|
|
123
|
+
frameMs: 4000,
|
|
124
|
+
blankMs: 240,
|
|
125
|
+
setupHint: copy.multipartOpenHint,
|
|
126
|
+
partLabel: copy.multipartPartLabel,
|
|
127
|
+
}).replaceAll("<", "\\u003c");
|
|
128
|
+
const transferVisual = isMultipart
|
|
129
|
+
? `
|
|
130
|
+
<div class="multipart-live" id="multipart-live">
|
|
131
|
+
<div class="multipart-panel multipart-setup" id="multipart-setup">
|
|
132
|
+
<div class="qr-item multipart-setup__item">
|
|
133
|
+
<div class="qr-frame qr-frame--large">${miniProgramVisual}</div>
|
|
134
|
+
<strong>${escapeHtml(copy.multipartOpenTitle)}</strong>
|
|
135
|
+
<span id="multipart-setup-hint">${escapeHtml(formatCopy(copy.multipartOpenHint, { seconds: multipartSetupSeconds }))}</span>
|
|
136
|
+
</div>
|
|
137
|
+
</div>
|
|
138
|
+
<div class="multipart-panel multipart-stage" id="multipart-stage" hidden>
|
|
139
|
+
<div class="qr-frame qr-frame--large"><img id="multipart-active-qr" alt="${escapeHtml(copy.dataQrAlt)}"></div>
|
|
140
|
+
<strong class="multipart-stage__title">${escapeHtml(copy.multipartTransferTitle)}</strong>
|
|
141
|
+
<span class="multipart-stage__part" id="multipart-part-label">${escapeHtml(formatCopy(copy.multipartPartLabel, { current: 1, total: qrUrls.length }))}</span>
|
|
142
|
+
<span class="multipart-stage__hint">${escapeHtml(copy.multipartTransferHint)}</span>
|
|
143
|
+
<button class="multipart-secondary" id="multipart-show-mini" type="button">${escapeHtml(copy.multipartRestart)}</button>
|
|
144
|
+
</div>
|
|
145
|
+
</div>
|
|
146
|
+
<div class="qr-grid qr-grid--export-only" hidden>
|
|
147
|
+
<div class="qr-item">
|
|
148
|
+
<div class="qr-frame">${miniProgramVisual}</div>
|
|
149
|
+
<strong>${escapeHtml(copy.openMiniProgram)}</strong>
|
|
150
|
+
<span>${escapeHtml(copy.openMiniProgramHint)}</span>
|
|
151
|
+
</div>
|
|
152
|
+
${dataQrItems}
|
|
153
|
+
</div>`
|
|
154
|
+
: `
|
|
155
|
+
<div class="qr-grid qr-grid--single">
|
|
156
|
+
<div class="qr-item">
|
|
157
|
+
<div class="qr-frame">${miniProgramVisual}</div>
|
|
158
|
+
<strong>${escapeHtml(copy.openMiniProgram)}</strong>
|
|
159
|
+
<span>${escapeHtml(copy.openMiniProgramHint)}</span>
|
|
160
|
+
</div>
|
|
161
|
+
${dataQrItems}
|
|
162
|
+
</div>`;
|
|
88
163
|
|
|
89
164
|
return `<!doctype html>
|
|
90
165
|
<html lang="${escapeHtml(copy.htmlLang)}" data-theme="${escapeHtml(record.presentation.default_theme)}">
|
|
@@ -302,11 +377,28 @@ export function renderHtml({ record, dataQrDataUrl, miniProgramCodeDataUrl = nul
|
|
|
302
377
|
color: var(--muted);
|
|
303
378
|
}
|
|
304
379
|
.barcode {
|
|
380
|
+
display: flex;
|
|
381
|
+
align-items: stretch;
|
|
382
|
+
box-sizing: border-box;
|
|
383
|
+
width: 100%;
|
|
305
384
|
height: 48px;
|
|
306
385
|
margin: 18px auto 8px;
|
|
307
|
-
max-width:
|
|
308
|
-
|
|
309
|
-
|
|
386
|
+
max-width: 320px;
|
|
387
|
+
padding: 0 20px;
|
|
388
|
+
overflow: hidden;
|
|
389
|
+
}
|
|
390
|
+
.barcode-segment {
|
|
391
|
+
display: block;
|
|
392
|
+
flex-basis: 0;
|
|
393
|
+
flex-shrink: 0;
|
|
394
|
+
min-width: 0;
|
|
395
|
+
height: 100%;
|
|
396
|
+
}
|
|
397
|
+
.barcode-segment--bar {
|
|
398
|
+
background: var(--ink);
|
|
399
|
+
}
|
|
400
|
+
.barcode-segment--space {
|
|
401
|
+
background: transparent;
|
|
310
402
|
}
|
|
311
403
|
.footer {
|
|
312
404
|
text-align: center;
|
|
@@ -341,6 +433,39 @@ export function renderHtml({ record, dataQrDataUrl, miniProgramCodeDataUrl = nul
|
|
|
341
433
|
.qr-frame img { display: block; width: 100%; height: 100%; object-fit: contain; }
|
|
342
434
|
.qr-item strong { display: block; font-size: 12px; }
|
|
343
435
|
.qr-item span { display: block; margin-top: 4px; color: var(--muted); font-size: 10px; line-height: 1.45; }
|
|
436
|
+
.qr-grid--export-only { display: none; }
|
|
437
|
+
.multipart-live { margin-top: 20px; }
|
|
438
|
+
.multipart-panel {
|
|
439
|
+
display: grid;
|
|
440
|
+
place-items: center;
|
|
441
|
+
min-height: 310px;
|
|
442
|
+
padding: 18px;
|
|
443
|
+
border: 1px solid var(--line);
|
|
444
|
+
background: color-mix(in srgb, var(--paper) 94%, var(--ink));
|
|
445
|
+
text-align: center;
|
|
446
|
+
}
|
|
447
|
+
.multipart-panel[hidden] { display: none; }
|
|
448
|
+
.multipart-setup__item { width: 100%; }
|
|
449
|
+
.qr-frame--large { width: min(100%, 250px); }
|
|
450
|
+
.multipart-stage__title,
|
|
451
|
+
.multipart-stage__part,
|
|
452
|
+
.multipart-stage__hint { display: block; }
|
|
453
|
+
.multipart-stage__title { margin-top: 4px; font-size: 14px; }
|
|
454
|
+
.multipart-stage__part { margin-top: 8px; font-size: 12px; font-weight: 800; letter-spacing: .06em; }
|
|
455
|
+
.multipart-stage__hint { max-width: 360px; margin-top: 6px; color: var(--muted); font-size: 10px; line-height: 1.5; }
|
|
456
|
+
#multipart-active-qr[aria-busy="true"] { visibility: hidden; }
|
|
457
|
+
.multipart-secondary {
|
|
458
|
+
margin-top: 14px;
|
|
459
|
+
padding: 7px 12px;
|
|
460
|
+
border: 1px solid var(--line);
|
|
461
|
+
border-radius: 999px;
|
|
462
|
+
color: var(--ink);
|
|
463
|
+
background: transparent;
|
|
464
|
+
font: inherit;
|
|
465
|
+
font-size: 10px;
|
|
466
|
+
cursor: pointer;
|
|
467
|
+
}
|
|
468
|
+
.multipart-secondary:hover { background: color-mix(in srgb, var(--ink) 7%, transparent); }
|
|
344
469
|
.mini-placeholder {
|
|
345
470
|
display: grid;
|
|
346
471
|
place-content: center;
|
|
@@ -425,7 +550,7 @@ export function renderHtml({ record, dataQrDataUrl, miniProgramCodeDataUrl = nul
|
|
|
425
550
|
<strong>${escapeHtml(formatNumber(compensation.amount, locale))}<small>${escapeHtml(compensation.unit)}</small></strong>
|
|
426
551
|
</div>
|
|
427
552
|
</section>
|
|
428
|
-
|
|
553
|
+
${receiptBarcode(receiptNumber)}
|
|
429
554
|
<footer class="footer">
|
|
430
555
|
<div>MODEL · ${escapeHtml(modelLabel)}</div>
|
|
431
556
|
<div>${escapeHtml(copy.footerThanks)}</div>
|
|
@@ -437,18 +562,7 @@ export function renderHtml({ record, dataQrDataUrl, miniProgramCodeDataUrl = nul
|
|
|
437
562
|
<h2>${escapeHtml(copy.transferTitle)}</h2>
|
|
438
563
|
<p>${escapeHtml(copy.transferDescription)}</p>
|
|
439
564
|
</header>
|
|
440
|
-
|
|
441
|
-
<div class="qr-item">
|
|
442
|
-
<div class="qr-frame">${miniProgramVisual}</div>
|
|
443
|
-
<strong>${escapeHtml(copy.openMiniProgram)}</strong>
|
|
444
|
-
<span>${escapeHtml(copy.openMiniProgramHint)}</span>
|
|
445
|
-
</div>
|
|
446
|
-
<div class="qr-item">
|
|
447
|
-
<div class="qr-frame"><img src="${dataQrDataUrl}" alt="${escapeHtml(copy.dataQrAlt)}"></div>
|
|
448
|
-
<strong>${escapeHtml(copy.importData)}</strong>
|
|
449
|
-
<span>${escapeHtml(copy.importDataHint)}</span>
|
|
450
|
-
</div>
|
|
451
|
-
</div>
|
|
565
|
+
${transferVisual}
|
|
452
566
|
<p class="transfer-note">${escapeHtml(copy.transferNote)}</p>
|
|
453
567
|
</section>
|
|
454
568
|
</div>
|
|
@@ -458,6 +572,7 @@ export function renderHtml({ record, dataQrDataUrl, miniProgramCodeDataUrl = nul
|
|
|
458
572
|
<script>${inlineScript(DOM_TO_IMAGE_SOURCE)}</script>
|
|
459
573
|
<script>
|
|
460
574
|
const exportConfig = ${exportConfig};
|
|
575
|
+
const multipartConfig = ${multipartConfig};
|
|
461
576
|
const themes = new Set(["classic", "diner", "payroll"]);
|
|
462
577
|
const buttons = [...document.querySelectorAll("[data-theme-value]")];
|
|
463
578
|
function applyTheme(theme) {
|
|
@@ -471,6 +586,88 @@ export function renderHtml({ record, dataQrDataUrl, miniProgramCodeDataUrl = nul
|
|
|
471
586
|
try { savedTheme = localStorage.getItem("codex-work-receipt-theme"); } catch {}
|
|
472
587
|
applyTheme(savedTheme || document.documentElement.dataset.theme);
|
|
473
588
|
|
|
589
|
+
const multipartLive = document.getElementById("multipart-live");
|
|
590
|
+
if (multipartConfig.enabled && multipartLive) {
|
|
591
|
+
const setup = document.getElementById("multipart-setup");
|
|
592
|
+
const setupHint = document.getElementById("multipart-setup-hint");
|
|
593
|
+
const stage = document.getElementById("multipart-stage");
|
|
594
|
+
const activeQr = document.getElementById("multipart-active-qr");
|
|
595
|
+
const partLabel = document.getElementById("multipart-part-label");
|
|
596
|
+
const showMiniButton = document.getElementById("multipart-show-mini");
|
|
597
|
+
const urls = [...document.querySelectorAll(".qr-grid--export-only [data-data-qr-index] img")]
|
|
598
|
+
.map((image) => image.src)
|
|
599
|
+
.filter(Boolean);
|
|
600
|
+
let setupTimer = null;
|
|
601
|
+
let rotationTimer = null;
|
|
602
|
+
let switchTimer = null;
|
|
603
|
+
let activeIndex = 0;
|
|
604
|
+
|
|
605
|
+
function stopMultipartTimers() {
|
|
606
|
+
if (setupTimer !== null) clearInterval(setupTimer);
|
|
607
|
+
if (rotationTimer !== null) clearInterval(rotationTimer);
|
|
608
|
+
if (switchTimer !== null) clearTimeout(switchTimer);
|
|
609
|
+
setupTimer = null;
|
|
610
|
+
rotationTimer = null;
|
|
611
|
+
switchTimer = null;
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
function setupHintText(seconds) {
|
|
615
|
+
return String(multipartConfig.setupHint).replaceAll("{seconds}", String(seconds));
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
function partLabelText(index) {
|
|
619
|
+
return String(multipartConfig.partLabel)
|
|
620
|
+
.replaceAll("{current}", String(index + 1))
|
|
621
|
+
.replaceAll("{total}", String(urls.length));
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
function showPart(index, immediate = false) {
|
|
625
|
+
if (!urls.length) return;
|
|
626
|
+
activeIndex = (index + urls.length) % urls.length;
|
|
627
|
+
const applyPart = () => {
|
|
628
|
+
activeQr.src = urls[activeIndex];
|
|
629
|
+
activeQr.alt = partLabelText(activeIndex);
|
|
630
|
+
activeQr.setAttribute("aria-busy", "false");
|
|
631
|
+
partLabel.textContent = partLabelText(activeIndex);
|
|
632
|
+
switchTimer = null;
|
|
633
|
+
};
|
|
634
|
+
if (immediate) {
|
|
635
|
+
applyPart();
|
|
636
|
+
return;
|
|
637
|
+
}
|
|
638
|
+
activeQr.setAttribute("aria-busy", "true");
|
|
639
|
+
switchTimer = setTimeout(applyPart, multipartConfig.blankMs);
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
function startMultipartTransfer() {
|
|
643
|
+
stopMultipartTimers();
|
|
644
|
+
setup.hidden = true;
|
|
645
|
+
stage.hidden = false;
|
|
646
|
+
showPart(0, true);
|
|
647
|
+
rotationTimer = setInterval(() => showPart(activeIndex + 1), multipartConfig.frameMs);
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
function showMultipartSetup() {
|
|
651
|
+
stopMultipartTimers();
|
|
652
|
+
stage.hidden = true;
|
|
653
|
+
setup.hidden = false;
|
|
654
|
+
let remaining = multipartConfig.setupSeconds;
|
|
655
|
+
setupHint.textContent = setupHintText(remaining);
|
|
656
|
+
setupTimer = setInterval(() => {
|
|
657
|
+
remaining -= 1;
|
|
658
|
+
if (remaining <= 0) {
|
|
659
|
+
startMultipartTransfer();
|
|
660
|
+
return;
|
|
661
|
+
}
|
|
662
|
+
setupHint.textContent = setupHintText(remaining);
|
|
663
|
+
}, 1000);
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
showMiniButton.addEventListener("click", showMultipartSetup);
|
|
667
|
+
window.addEventListener("beforeunload", stopMultipartTimers);
|
|
668
|
+
showMultipartSetup();
|
|
669
|
+
}
|
|
670
|
+
|
|
474
671
|
const exportButton = document.getElementById("save-receipt-image");
|
|
475
672
|
const exportStatus = document.getElementById("export-status");
|
|
476
673
|
const exportNode = document.getElementById("receipt-export");
|
|
@@ -481,7 +678,7 @@ export function renderHtml({ record, dataQrDataUrl, miniProgramCodeDataUrl = nul
|
|
|
481
678
|
}
|
|
482
679
|
|
|
483
680
|
function waitForImages(node) {
|
|
484
|
-
return Promise.all([...node.querySelectorAll("img")].map((image) => {
|
|
681
|
+
return Promise.all([...node.querySelectorAll("img")].filter((image) => image.getAttribute("src")).map((image) => {
|
|
485
682
|
if (image.complete && image.naturalWidth > 0) return Promise.resolve();
|
|
486
683
|
return new Promise((resolve, reject) => {
|
|
487
684
|
image.addEventListener("load", resolve, { once: true });
|
|
@@ -524,6 +721,13 @@ export function renderHtml({ record, dataQrDataUrl, miniProgramCodeDataUrl = nul
|
|
|
524
721
|
style: { background: paperColor },
|
|
525
722
|
onclone(clone) {
|
|
526
723
|
clone.style.background = paperColor;
|
|
724
|
+
const multipartLiveClone = clone.querySelector(".multipart-live");
|
|
725
|
+
if (multipartLiveClone) multipartLiveClone.remove();
|
|
726
|
+
const exportGridClone = clone.querySelector(".qr-grid--export-only");
|
|
727
|
+
if (exportGridClone) {
|
|
728
|
+
exportGridClone.removeAttribute("hidden");
|
|
729
|
+
exportGridClone.style.display = "grid";
|
|
730
|
+
}
|
|
527
731
|
clone.querySelectorAll(".paper").forEach((paper) => {
|
|
528
732
|
paper.style.boxShadow = "none";
|
|
529
733
|
});
|