codex-work-receipt 0.6.0 → 0.7.0

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.
@@ -1,8 +1,14 @@
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
- import { buildCompensation, DEFAULT_LOCALE, getReceiptCopy } from "../core/presentation.mjs";
6
+ import {
7
+ buildCompensation,
8
+ DEFAULT_LOCALE,
9
+ getReceiptCopy,
10
+ getScopeLabel,
11
+ } from "../core/presentation.mjs";
6
12
 
7
13
  const require = createRequire(import.meta.url);
8
14
  const DOM_TO_IMAGE_SOURCE = fs.readFileSync(require.resolve("dom-to-image-more"), "utf8");
@@ -24,6 +30,16 @@ function receiptRow(label, value, emphasize = false) {
24
30
  return `<div class="receipt-row${emphasize ? " receipt-row--emphasize" : ""}"><span>${escapeHtml(label)}</span><strong>${escapeHtml(value)}</strong></div>`;
25
31
  }
26
32
 
33
+ function receiptBarcode(value) {
34
+ const barcode = buildCode128B(value);
35
+ const segments = barcode.segments.map((segment) => {
36
+ const kind = segment.isBar ? "bar" : "space";
37
+ return `<span class="barcode-segment barcode-segment--${kind}" style="flex-grow:${segment.width}"></span>`;
38
+ }).join("");
39
+
40
+ return `<div class="barcode" data-barcode-value="${escapeHtml(barcode.value)}" aria-hidden="true">${segments}</div>`;
41
+ }
42
+
27
43
  function formatCount(value, units, locale) {
28
44
  const amount = Math.max(0, Math.round(value || 0));
29
45
  const unit = units[amount === 1 ? 0 : 1];
@@ -36,6 +52,13 @@ function formatDateKey(value, locale) {
36
52
  return locale === "en" ? `${match[2]}/${match[3]}/${match[1]}` : `${match[1]}/${match[2]}/${match[3]}`;
37
53
  }
38
54
 
55
+ function formatCopy(template, values) {
56
+ return Object.entries(values).reduce(
57
+ (result, [key, value]) => result.replaceAll(`{${key}}`, String(value)),
58
+ String(template),
59
+ );
60
+ }
61
+
39
62
  export function renderHtml({ record, dataQrDataUrl = null, dataQrDataUrls = null, miniProgramCodeDataUrl = null }) {
40
63
  const locale = record.locale || DEFAULT_LOCALE;
41
64
  const copy = getReceiptCopy(locale);
@@ -47,7 +70,7 @@ export function renderHtml({ record, dataQrDataUrl = null, dataQrDataUrls = null
47
70
  const rangeEndDate = record.period.range_end_date || formatDateKey(record.period.end_at.slice(0, 10), "zh-CN").replaceAll("/", "-");
48
71
  const isCalendarScope = new Set(["today", "last-7-days", "this-week"]).has(record.source.scope);
49
72
  const spansMultipleDates = rangeStartDate !== rangeEndDate;
50
- const scopeLabel = copy.scope[record.source.scope] || copy.scope.latest;
73
+ const scopeLabel = getScopeLabel(record.source.scope, locale, record.source.hours);
51
74
  const dateRange = spansMultipleDates
52
75
  ? `${formatDateKey(rangeStartDate, locale)}—${formatDateKey(rangeEndDate, locale)}`
53
76
  : formatDateKey(rangeEndDate, locale);
@@ -56,7 +79,8 @@ export function renderHtml({ record, dataQrDataUrl = null, dataQrDataUrls = null
56
79
  : `${formatTime(startAt, timezone, locale)}—${formatTime(endAt, timezone, locale)}`;
57
80
  const businessPeriodLabel = isCalendarScope ? copy.meta.period : copy.meta.hours;
58
81
  const modelLabel = record.stats.models.length ? record.stats.models.join(" / ") : copy.modelMissing;
59
- const receiptNumber = `${record.id.slice(4, 12).toUpperCase()}-${String(record.stats.completed_turns).padStart(3, "0")}`;
82
+ const shortReceiptId = record.id.replace(/^cwr2?_/, "").slice(0, 8).toUpperCase() || "UNKNOWN";
83
+ const receiptNumber = `${shortReceiptId}-${String(record.stats.completed_turns).padStart(3, "0")}`;
60
84
  const compensation = record.presentation.compensation || buildCompensation(record.source.scope, 0, locale);
61
85
  const responseSeconds = new Intl.NumberFormat(locale === "en" ? "en-US" : "zh-CN", {
62
86
  minimumFractionDigits: 1,
@@ -91,11 +115,56 @@ export function renderHtml({ record, dataQrDataUrl = null, dataQrDataUrls = null
91
115
  ? [dataQrDataUrl]
92
116
  : [];
93
117
  const dataQrItems = qrUrls.map((url, index) => `
94
- <div class="qr-item">
118
+ <div class="qr-item" data-data-qr-index="${index}">
95
119
  <div class="qr-frame"><img src="${url}" alt="${escapeHtml(copy.dataQrAlt)} ${index + 1}/${qrUrls.length}"></div>
96
120
  <strong>${escapeHtml(copy.importData)}${qrUrls.length > 1 ? ` ${index + 1}/${qrUrls.length}` : ""}</strong>
97
121
  <span>${escapeHtml(copy.importDataHint)}</span>
98
122
  </div>`).join("");
123
+ const isMultipart = qrUrls.length > 1;
124
+ const multipartSetupSeconds = 10;
125
+ const multipartConfig = JSON.stringify({
126
+ enabled: isMultipart,
127
+ setupSeconds: multipartSetupSeconds,
128
+ frameMs: 4000,
129
+ blankMs: 240,
130
+ setupHint: copy.multipartOpenHint,
131
+ partLabel: copy.multipartPartLabel,
132
+ }).replaceAll("<", "\\u003c");
133
+ const transferVisual = isMultipart
134
+ ? `
135
+ <div class="multipart-live" id="multipart-live">
136
+ <div class="multipart-panel multipart-setup" id="multipart-setup">
137
+ <div class="qr-item multipart-setup__item">
138
+ <div class="qr-frame qr-frame--large">${miniProgramVisual}</div>
139
+ <strong>${escapeHtml(copy.multipartOpenTitle)}</strong>
140
+ <span id="multipart-setup-hint">${escapeHtml(formatCopy(copy.multipartOpenHint, { seconds: multipartSetupSeconds }))}</span>
141
+ </div>
142
+ </div>
143
+ <div class="multipart-panel multipart-stage" id="multipart-stage" hidden>
144
+ <div class="qr-frame qr-frame--large"><img id="multipart-active-qr" alt="${escapeHtml(copy.dataQrAlt)}"></div>
145
+ <strong class="multipart-stage__title">${escapeHtml(copy.multipartTransferTitle)}</strong>
146
+ <span class="multipart-stage__part" id="multipart-part-label">${escapeHtml(formatCopy(copy.multipartPartLabel, { current: 1, total: qrUrls.length }))}</span>
147
+ <span class="multipart-stage__hint">${escapeHtml(copy.multipartTransferHint)}</span>
148
+ <button class="multipart-secondary" id="multipart-show-mini" type="button">${escapeHtml(copy.multipartRestart)}</button>
149
+ </div>
150
+ </div>
151
+ <div class="qr-grid qr-grid--export-only" hidden>
152
+ <div class="qr-item">
153
+ <div class="qr-frame">${miniProgramVisual}</div>
154
+ <strong>${escapeHtml(copy.openMiniProgram)}</strong>
155
+ <span>${escapeHtml(copy.openMiniProgramHint)}</span>
156
+ </div>
157
+ ${dataQrItems}
158
+ </div>`
159
+ : `
160
+ <div class="qr-grid qr-grid--single">
161
+ <div class="qr-item">
162
+ <div class="qr-frame">${miniProgramVisual}</div>
163
+ <strong>${escapeHtml(copy.openMiniProgram)}</strong>
164
+ <span>${escapeHtml(copy.openMiniProgramHint)}</span>
165
+ </div>
166
+ ${dataQrItems}
167
+ </div>`;
99
168
 
100
169
  return `<!doctype html>
101
170
  <html lang="${escapeHtml(copy.htmlLang)}" data-theme="${escapeHtml(record.presentation.default_theme)}">
@@ -313,11 +382,28 @@ export function renderHtml({ record, dataQrDataUrl = null, dataQrDataUrls = null
313
382
  color: var(--muted);
314
383
  }
315
384
  .barcode {
385
+ display: flex;
386
+ align-items: stretch;
387
+ box-sizing: border-box;
388
+ width: 100%;
316
389
  height: 48px;
317
390
  margin: 18px auto 8px;
318
- max-width: 280px;
319
- background: repeating-linear-gradient(90deg, var(--ink) 0 2px, transparent 2px 4px, var(--ink) 4px 5px, transparent 5px 8px);
320
- opacity: .84;
391
+ max-width: 320px;
392
+ padding: 0 20px;
393
+ overflow: hidden;
394
+ }
395
+ .barcode-segment {
396
+ display: block;
397
+ flex-basis: 0;
398
+ flex-shrink: 0;
399
+ min-width: 0;
400
+ height: 100%;
401
+ }
402
+ .barcode-segment--bar {
403
+ background: var(--ink);
404
+ }
405
+ .barcode-segment--space {
406
+ background: transparent;
321
407
  }
322
408
  .footer {
323
409
  text-align: center;
@@ -352,6 +438,39 @@ export function renderHtml({ record, dataQrDataUrl = null, dataQrDataUrls = null
352
438
  .qr-frame img { display: block; width: 100%; height: 100%; object-fit: contain; }
353
439
  .qr-item strong { display: block; font-size: 12px; }
354
440
  .qr-item span { display: block; margin-top: 4px; color: var(--muted); font-size: 10px; line-height: 1.45; }
441
+ .qr-grid--export-only { display: none; }
442
+ .multipart-live { margin-top: 20px; }
443
+ .multipart-panel {
444
+ display: grid;
445
+ place-items: center;
446
+ min-height: 310px;
447
+ padding: 18px;
448
+ border: 1px solid var(--line);
449
+ background: color-mix(in srgb, var(--paper) 94%, var(--ink));
450
+ text-align: center;
451
+ }
452
+ .multipart-panel[hidden] { display: none; }
453
+ .multipart-setup__item { width: 100%; }
454
+ .qr-frame--large { width: min(100%, 250px); }
455
+ .multipart-stage__title,
456
+ .multipart-stage__part,
457
+ .multipart-stage__hint { display: block; }
458
+ .multipart-stage__title { margin-top: 4px; font-size: 14px; }
459
+ .multipart-stage__part { margin-top: 8px; font-size: 12px; font-weight: 800; letter-spacing: .06em; }
460
+ .multipart-stage__hint { max-width: 360px; margin-top: 6px; color: var(--muted); font-size: 10px; line-height: 1.5; }
461
+ #multipart-active-qr[aria-busy="true"] { visibility: hidden; }
462
+ .multipart-secondary {
463
+ margin-top: 14px;
464
+ padding: 7px 12px;
465
+ border: 1px solid var(--line);
466
+ border-radius: 999px;
467
+ color: var(--ink);
468
+ background: transparent;
469
+ font: inherit;
470
+ font-size: 10px;
471
+ cursor: pointer;
472
+ }
473
+ .multipart-secondary:hover { background: color-mix(in srgb, var(--ink) 7%, transparent); }
355
474
  .mini-placeholder {
356
475
  display: grid;
357
476
  place-content: center;
@@ -436,7 +555,7 @@ export function renderHtml({ record, dataQrDataUrl = null, dataQrDataUrls = null
436
555
  <strong>${escapeHtml(formatNumber(compensation.amount, locale))}<small>${escapeHtml(compensation.unit)}</small></strong>
437
556
  </div>
438
557
  </section>
439
- <div class="barcode" aria-hidden="true"></div>
558
+ ${receiptBarcode(receiptNumber)}
440
559
  <footer class="footer">
441
560
  <div>MODEL · ${escapeHtml(modelLabel)}</div>
442
561
  <div>${escapeHtml(copy.footerThanks)}</div>
@@ -448,14 +567,7 @@ export function renderHtml({ record, dataQrDataUrl = null, dataQrDataUrls = null
448
567
  <h2>${escapeHtml(copy.transferTitle)}</h2>
449
568
  <p>${escapeHtml(copy.transferDescription)}</p>
450
569
  </header>
451
- <div class="qr-grid">
452
- <div class="qr-item">
453
- <div class="qr-frame">${miniProgramVisual}</div>
454
- <strong>${escapeHtml(copy.openMiniProgram)}</strong>
455
- <span>${escapeHtml(copy.openMiniProgramHint)}</span>
456
- </div>
457
- ${dataQrItems}
458
- </div>
570
+ ${transferVisual}
459
571
  <p class="transfer-note">${escapeHtml(copy.transferNote)}</p>
460
572
  </section>
461
573
  </div>
@@ -465,6 +577,7 @@ export function renderHtml({ record, dataQrDataUrl = null, dataQrDataUrls = null
465
577
  <script>${inlineScript(DOM_TO_IMAGE_SOURCE)}</script>
466
578
  <script>
467
579
  const exportConfig = ${exportConfig};
580
+ const multipartConfig = ${multipartConfig};
468
581
  const themes = new Set(["classic", "diner", "payroll"]);
469
582
  const buttons = [...document.querySelectorAll("[data-theme-value]")];
470
583
  function applyTheme(theme) {
@@ -478,6 +591,88 @@ export function renderHtml({ record, dataQrDataUrl = null, dataQrDataUrls = null
478
591
  try { savedTheme = localStorage.getItem("codex-work-receipt-theme"); } catch {}
479
592
  applyTheme(savedTheme || document.documentElement.dataset.theme);
480
593
 
594
+ const multipartLive = document.getElementById("multipart-live");
595
+ if (multipartConfig.enabled && multipartLive) {
596
+ const setup = document.getElementById("multipart-setup");
597
+ const setupHint = document.getElementById("multipart-setup-hint");
598
+ const stage = document.getElementById("multipart-stage");
599
+ const activeQr = document.getElementById("multipart-active-qr");
600
+ const partLabel = document.getElementById("multipart-part-label");
601
+ const showMiniButton = document.getElementById("multipart-show-mini");
602
+ const urls = [...document.querySelectorAll(".qr-grid--export-only [data-data-qr-index] img")]
603
+ .map((image) => image.src)
604
+ .filter(Boolean);
605
+ let setupTimer = null;
606
+ let rotationTimer = null;
607
+ let switchTimer = null;
608
+ let activeIndex = 0;
609
+
610
+ function stopMultipartTimers() {
611
+ if (setupTimer !== null) clearInterval(setupTimer);
612
+ if (rotationTimer !== null) clearInterval(rotationTimer);
613
+ if (switchTimer !== null) clearTimeout(switchTimer);
614
+ setupTimer = null;
615
+ rotationTimer = null;
616
+ switchTimer = null;
617
+ }
618
+
619
+ function setupHintText(seconds) {
620
+ return String(multipartConfig.setupHint).replaceAll("{seconds}", String(seconds));
621
+ }
622
+
623
+ function partLabelText(index) {
624
+ return String(multipartConfig.partLabel)
625
+ .replaceAll("{current}", String(index + 1))
626
+ .replaceAll("{total}", String(urls.length));
627
+ }
628
+
629
+ function showPart(index, immediate = false) {
630
+ if (!urls.length) return;
631
+ activeIndex = (index + urls.length) % urls.length;
632
+ const applyPart = () => {
633
+ activeQr.src = urls[activeIndex];
634
+ activeQr.alt = partLabelText(activeIndex);
635
+ activeQr.setAttribute("aria-busy", "false");
636
+ partLabel.textContent = partLabelText(activeIndex);
637
+ switchTimer = null;
638
+ };
639
+ if (immediate) {
640
+ applyPart();
641
+ return;
642
+ }
643
+ activeQr.setAttribute("aria-busy", "true");
644
+ switchTimer = setTimeout(applyPart, multipartConfig.blankMs);
645
+ }
646
+
647
+ function startMultipartTransfer() {
648
+ stopMultipartTimers();
649
+ setup.hidden = true;
650
+ stage.hidden = false;
651
+ showPart(0, true);
652
+ rotationTimer = setInterval(() => showPart(activeIndex + 1), multipartConfig.frameMs);
653
+ }
654
+
655
+ function showMultipartSetup() {
656
+ stopMultipartTimers();
657
+ stage.hidden = true;
658
+ setup.hidden = false;
659
+ let remaining = multipartConfig.setupSeconds;
660
+ setupHint.textContent = setupHintText(remaining);
661
+ setupTimer = setInterval(() => {
662
+ remaining -= 1;
663
+ if (remaining <= 0) {
664
+ startMultipartTransfer();
665
+ return;
666
+ }
667
+ setupHint.textContent = setupHintText(remaining);
668
+ }, 1000);
669
+ }
670
+
671
+ showMiniButton.addEventListener("click", showMultipartSetup);
672
+ window.addEventListener("beforeunload", stopMultipartTimers);
673
+ showMultipartSetup();
674
+ }
675
+
481
676
  const exportButton = document.getElementById("save-receipt-image");
482
677
  const exportStatus = document.getElementById("export-status");
483
678
  const exportNode = document.getElementById("receipt-export");
@@ -488,7 +683,7 @@ export function renderHtml({ record, dataQrDataUrl = null, dataQrDataUrls = null
488
683
  }
489
684
 
490
685
  function waitForImages(node) {
491
- return Promise.all([...node.querySelectorAll("img")].map((image) => {
686
+ return Promise.all([...node.querySelectorAll("img")].filter((image) => image.getAttribute("src")).map((image) => {
492
687
  if (image.complete && image.naturalWidth > 0) return Promise.resolve();
493
688
  return new Promise((resolve, reject) => {
494
689
  image.addEventListener("load", resolve, { once: true });
@@ -531,6 +726,13 @@ export function renderHtml({ record, dataQrDataUrl = null, dataQrDataUrls = null
531
726
  style: { background: paperColor },
532
727
  onclone(clone) {
533
728
  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
+ }
534
736
  clone.querySelectorAll(".paper").forEach((paper) => {
535
737
  paper.style.boxShadow = "none";
536
738
  });