2 Commits

Author SHA1 Message Date
julian bbf61c48df fix(ticket): 11-digit IDs — fix KP-300H barcode line-overflow
The Cashino KP-300H printed entry tickets as raster garbage (solid black
bars / banding) while the Rongta printed the same byte stream fine. Root
cause: the barcode overflowed the print line, not data corruption.

A 13-digit Code128 at module width 3 is ~534 dots. The KP-300H prints 72mm
(512 usable dots at 203 dpi), so the symbol overran the line and the firmware
rendered the overflow as raster noise. The Rongta runs 80mm (576 dots) and had
just enough room — which is why only the Cashino failed. Confirmed on hardware:
plain text printed clean, the barcode was the trigger, and an 11-digit code at
width 3 (~468 dots) both fits and scans the full value at the exit reader.

- Ticket IDs reduced 13 → 11 digits (10 random + Luhn). Length is driven by
  guess-resistance (10^10 space, ~1-in-10^7 to hit a live OPEN ticket even with
  thousands parked — the booth-operator threat model), not volume.
- validateTicketCode is now length-agnostic (\d{10,14} + Luhn) so legacy
  13-digit tickets still in circulation keep validating; the id stays opaque.

Also: sendRaw now closes the print socket GRACEFULLY (end()+FIN, wait for
close) instead of write-then-destroy, which could RST mid-stream and truncate a
job. A separate latent bug found while diagnosing, fixed here.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-19 11:35:13 +02:00
julian 00f3d141b6 feat: human + relative dates; fix language/theme toggle stale-context
Dates were raw ISO on printed slips and time-only in the UI (a session from
two days ago showed just "10:48"). Make them human and day-relative. Also fix
a latent toggle bug surfaced while testing.

Dates:
- Printed tickets/receipts/subscription cards now show "19 Qershor 2026
  10:48:25" (Albanian month, 24h with seconds) instead of YYYY-MM-DD HH:MM.
  stamp() exported as formatStampSq so the shift Z-report shares it.
- Shift Z-report is now Albanian (Operatori/Nga/Deri/Para në dorë/Arka…),
  was English-only with ISO dates.
- Web sessions/logs/history show relative days: "Sot 10:48" / "Dje 17:33" /
  "17 Qershor 10:48" via formatRelativeDateTime(). Month names come from the
  i18n catalog (common.months), NOT Intl — the appliance browser's ICU lacks
  Albanian locale data and Intl silently falls back to English month names.

Toggle fix:
- The language + theme toggles read the active value from the TanStack Router
  context `user`, which is captured at route-resolution time and does not
  re-render on setUser. After one switch the highlight froze and the equality
  guard blocked switching back until a page refresh. Drive them off live state
  instead: language from i18n.language (useTranslation subscribes to
  languageChanged), theme from local useState. (Bug dated to 040c0ff.)

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-19 11:14:06 +02:00
11 changed files with 228 additions and 66 deletions
+19 -9
View File
@@ -219,18 +219,24 @@ export class EntryFlow {
/** /**
* Opaque, unguessable transient ticket id (wiki/concepts/ticket-encoding.md). * Opaque, unguessable transient ticket id (wiki/concepts/ticket-encoding.md).
* *
* Format: 13 digits = 12 cryptographically-random digits + 1 trailing Luhn check * Format: 11 digits = 10 cryptographically-random digits + 1 trailing Luhn check
* digit. All-numeric so the booth can read it on ANY legacy 1D barcode scanner and * digit. All-numeric so the booth can read it on ANY legacy 1D barcode scanner and
* an operator can hand-key it if every reader is down. RANDOM (not sequential): the * an operator can hand-key it if every reader is down. RANDOM (not sequential): the
* id must stay unguessable so an attacker can't iterate to claim a cheaper session * id must stay unguessable so an attacker can't iterate to claim a cheaper session
* — the anti-fraud property the wiki settles. 12 random digits = 10^12 space, so * — the anti-fraud property the wiki settles.
* collisions are negligible at lot scale; the unique constraints on *
* ledger_events.index / sessions.id are the backstop. The Luhn digit lets a manual * Length is driven by GUESS-RESISTANCE, not volume: with 10^10 valid ids and the
* entry reject a typo (validateTicketCode) instead of failing as "session not found". * Luhn digit rejecting 9/10 of malformed guesses, a blind attempt at a currently-OPEN
* ticket lands at ~1-in-10^7 even with thousands parked — comfortably safe — while
* being two digits (≈2 barcode modules) narrower than the old 13. Collisions are
* negligible at lot scale; the unique constraints on ledger_events.index / sessions.id
* are the backstop. (Older 13-digit ids stay valid — the id is opaque, length-agnostic.)
* The Luhn digit lets a manual entry reject a typo (validateTicketCode) instead of
* failing as "session not found".
*/ */
function newTicketId(): string { function newTicketId(): string {
let body = ""; let body = "";
for (let i = 0; i < 12; i += 1) body += String(randomInt(10)); for (let i = 0; i < 10; i += 1) body += String(randomInt(10));
return body + luhnCheckDigit(body); return body + luhnCheckDigit(body);
} }
@@ -260,7 +266,11 @@ function luhnCheckDigit(digits: string): string {
* never reject an id that already exists in the ledger. See ticket-encoding.md. * never reject an id that already exists in the ledger. See ticket-encoding.md.
*/ */
export function validateTicketCode(code: string): boolean { export function validateTicketCode(code: string): boolean {
if (!/^\d{13}$/.test(code)) return false; // Length-agnostic: an all-digit code whose last digit is the Luhn check of the rest.
const body = code.slice(0, 12); // Accepts the current 11-digit ids AND any legacy 13-digit ones still in circulation
return luhnCheckDigit(body) === code[12]; // (the id is opaque; only the digits+checksum shape matters). The 10..14 bound keeps
// a stray short/long string from being mistaken for a ticket. See ticket-encoding.md.
if (!/^\d{10,14}$/.test(code)) return false;
const body = code.slice(0, -1);
return luhnCheckDigit(body) === code[code.length - 1];
} }
+15 -13
View File
@@ -1,5 +1,5 @@
import { eq, devices, ledgerEvents, type Db } from "@parking/db"; import { eq, devices, ledgerEvents, type Db } from "@parking/db";
import { registry, type PrinterDevice } from "@parking/devices"; import { registry, formatStampSq as zStamp, type PrinterDevice } from "@parking/devices";
import type { LedgerPayload } from "@parking/shared"; import type { LedgerPayload } from "@parking/shared";
import type { FastifyBaseLogger } from "fastify"; import type { FastifyBaseLogger } from "fastify";
import type { EventLog } from "./event-log.js"; import type { EventLog } from "./event-log.js";
@@ -393,21 +393,23 @@ export class ShiftService {
} }
const cur = r.currency ?? ""; const cur = r.currency ?? "";
const money = (m: number) => (m / 100).toFixed(2); const money = (m: number) => (m / 100).toFixed(2);
// Customer/operator-facing print is Albanian (see i18n.md — printed slips are not
// governed by the UI language), with human dates "19 Qershor 2026 10:48:25".
const lines = [ const lines = [
`Operator: ${r.operator}`, `Operatori: ${r.operator}`,
`From: ${r.startedAt}`, `Nga: ${zStamp(r.startedAt)}`,
`To: ${r.endedAt}`, `Deri: ${zStamp(r.endedAt)}`,
"", "",
`Payments: ${r.paymentCount}`, `Pagesa: ${r.paymentCount}`,
`Cash: ${money(r.cashTotalMinor)} ${cur}`, `Para në dorë: ${money(r.cashTotalMinor)} ${cur}`,
`Card: ${money(r.cardTotalMinor)} ${cur}`, `Kartë: ${money(r.cardTotalMinor)} ${cur}`,
"", "",
"-- Drawer --", "-- Arka --",
`Opening float: ${money(r.openingFloatMinor)} ${cur}`, `Fillimi (kusur): ${money(r.openingFloatMinor)} ${cur}`,
`Cash taken: ${money(r.cashTotalMinor)} ${cur}`, `Para të marra: ${money(r.cashTotalMinor)} ${cur}`,
`Cash added: ${money(r.cashAddedMinor)} ${cur}`, `Para të shtuara: ${money(r.cashAddedMinor)} ${cur}`,
`Cash removed: ${money(r.cashRemovedMinor)} ${cur}`, `Para të hequra: ${money(r.cashRemovedMinor)} ${cur}`,
`Expected drawer: ${money(r.expectedDrawerMinor)} ${cur}`, `Arka e pritur: ${money(r.expectedDrawerMinor)} ${cur}`,
]; ];
try { try {
await printer.printReport({ title: "RAPORT TURNI", lines }); await printer.printReport({ title: "RAPORT TURNI", lines });
+2 -4
View File
@@ -4,7 +4,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { fetchActiveSessions, reopenBarrier, type ActiveSession } from "./api.js"; import { fetchActiveSessions, reopenBarrier, type ActiveSession } from "./api.js";
import { qk } from "./lib/query.js"; import { qk } from "./lib/query.js";
import { useShift } from "./lib/use-shift.js"; import { useShift } from "./lib/use-shift.js";
import { formatDuration, formatTime } from "./lib/format.js"; import { formatDuration, formatRelativeDateTime } from "./lib/format.js";
import { Panel } from "./ui/Panel.js"; import { Panel } from "./ui/Panel.js";
// Active Sessions panel. A session is "active" while still inside OR exited-but- // Active Sessions panel. A session is "active" while still inside OR exited-but-
@@ -94,9 +94,7 @@ export function ActiveSessions({ onPick }: { onPick: (identity: string) => void
<span className="text-term-text"> <span className="text-term-text">
{s.subscription ? `★ ${s.subscriptionHolder ?? t("subs.unnamed")}` : s.identity} {s.subscription ? `★ ${s.subscriptionHolder ?? t("subs.unnamed")}` : s.identity}
</span> </span>
<span className="text-term-muted"> <span className="text-term-muted">{formatRelativeDateTime(s.enteredAt, t)}</span>
{t("booth.inAt")} {formatTime(s.enteredAt)}
</span>
<span className="text-term-muted">{formatDuration(s.enteredAt, new Date().toISOString())}</span> <span className="text-term-muted">{formatDuration(s.enteredAt, new Date().toISOString())}</span>
<span className={`ml-auto w-16 text-right font-semibold uppercase ${badge.cls}`}>{t(badge.key)}</span> <span className={`ml-auto w-16 text-right font-semibold uppercase ${badge.cls}`}>{t(badge.key)}</span>
</button> </button>
+2 -2
View File
@@ -15,7 +15,7 @@ import {
} from "./api.js"; } from "./api.js";
import { qk } from "./lib/query.js"; import { qk } from "./lib/query.js";
import { useShift } from "./lib/use-shift.js"; import { useShift } from "./lib/use-shift.js";
import { formatDuration, formatMoney, formatTime } from "./lib/format.js"; import { formatDuration, formatMoney, formatTime, formatRelativeDateTime } from "./lib/format.js";
import { SnapshotStrip } from "./ui/SnapshotStrip.js"; import { SnapshotStrip } from "./ui/SnapshotStrip.js";
// The booth pay/exit modal. Opened when the operator submits a ticket id. Shows the // The booth pay/exit modal. Opened when the operator submits a ticket id. Shows the
@@ -213,7 +213,7 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
<> <>
{/* Session figures */} {/* Session figures */}
<div className="grid grid-cols-2 gap-x-6 gap-y-1 tabular-nums"> <div className="grid grid-cols-2 gap-x-6 gap-y-1 tabular-nums">
<Row label={t("pay.entry")} value={formatTime(s.enteredAt)} /> <Row label={t("pay.entry")} value={formatRelativeDateTime(s.enteredAt, t)} />
<Row label={t("pay.now")} value={formatTime(new Date().toISOString())} /> <Row label={t("pay.now")} value={formatTime(new Date().toISOString())} />
<Row <Row
label={t("pay.duration")} label={t("pay.duration")}
+4 -16
View File
@@ -2,7 +2,7 @@ import { useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { fetchShifts, type ShiftSummary, type SessionUser } from "./api.js"; import { fetchShifts, type ShiftSummary, type SessionUser } from "./api.js";
import { formatMoney, formatDuration } from "./lib/format.js"; import { formatMoney, formatDuration, formatRelativeDateTime } from "./lib/format.js";
// Completed shift history. Scope is enforced SERVER-SIDE by permission: an operator // Completed shift history. Scope is enforced SERVER-SIDE by permission: an operator
// gets only their own shifts; an admin (shift:cash) gets all + a date/operator // gets only their own shifts; an admin (shift:cash) gets all + a date/operator
@@ -10,19 +10,6 @@ import { formatMoney, formatDuration } from "./lib/format.js";
// reports scope:"all". Each row is one signed shift_z_report; expanding it shows the // reports scope:"all". Each row is one signed shift_z_report; expanding it shows the
// drawer reconciliation. See wiki/concepts/shift.md. // drawer reconciliation. See wiki/concepts/shift.md.
/** Local date + time (history spans days, so not just time-of-day). */
function fmtDateTime(iso: string): string {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return "—";
return d.toLocaleString(undefined, {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
});
}
function money(minor: number, currency: string | null): string { function money(minor: number, currency: string | null): string {
return currency ? formatMoney(minor, currency) : (minor / 100).toFixed(2); return currency ? formatMoney(minor, currency) : (minor / 100).toFixed(2);
} }
@@ -138,6 +125,7 @@ function ShiftRow({ s, showOperator, colSpan }: { s: ShiftSummary; showOperator:
const { t } = useTranslation(); const { t } = useTranslation();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const cur = s.currency; const cur = s.currency;
const when = (iso: string) => formatRelativeDateTime(iso, t);
return ( return (
<> <>
@@ -146,9 +134,9 @@ function ShiftRow({ s, showOperator, colSpan }: { s: ShiftSummary; showOperator:
onClick={() => setOpen((o) => !o)} onClick={() => setOpen((o) => !o)}
> >
{showOperator && <td className="px-3 py-1.5 text-term-text">{s.operator}</td>} {showOperator && <td className="px-3 py-1.5 text-term-text">{s.operator}</td>}
<td className="px-3 py-1.5">{fmtDateTime(s.startedAt)}</td> <td className="px-3 py-1.5">{when(s.startedAt)}</td>
<td className="px-3 py-1.5"> <td className="px-3 py-1.5">
{fmtDateTime(s.endedAt)} {when(s.endedAt)}
<span className="ml-2 text-term-muted">{formatDuration(s.startedAt, s.endedAt)}</span> <span className="ml-2 text-term-muted">{formatDuration(s.startedAt, s.endedAt)}</span>
</td> </td>
<td className="px-3 py-1.5 text-right">{s.paymentCount}</td> <td className="px-3 py-1.5 text-right">{s.paymentCount}</td>
+55
View File
@@ -28,3 +28,58 @@ export function formatTime(iso: string | null): string {
const d = new Date(iso); const d = new Date(iso);
return Number.isNaN(d.getTime()) ? "—" : d.toTimeString().slice(0, 8); return Number.isNaN(d.getTime()) ? "—" : d.toTimeString().slice(0, 8);
} }
/** Calendar-day difference (local) between two dates: 0 = same day, 1 = d is one day
* before ref, etc. Compares date parts only (ignores time-of-day). */
function dayDiff(d: Date, ref: Date): number {
const a = new Date(d.getFullYear(), d.getMonth(), d.getDate());
const b = new Date(ref.getFullYear(), ref.getMonth(), ref.getDate());
return Math.round((b.getTime() - a.getTime()) / 86_400_000);
}
/** HH:MM (local, 24h) for the relative-day labels. */
function hhmm(d: Date): string {
const p = (n: number) => String(n).padStart(2, "0");
return `${p(d.getHours())}:${p(d.getMinutes())}`;
}
/** Minimal shape of i18next's `t` that we rely on: a string lookup, plus the
* `returnObjects` overload used to fetch the month-name array. */
export interface TFn {
(key: string): string;
(key: string, opts: { returnObjects: true }): unknown;
}
/** Localized month name (index 0 = January) from the i18n catalog. Browser ICU on
* the appliance may lack Albanian data, so we DON'T use Intl — the catalog is the
* source of truth. Falls back to a numeric month if the array is missing. */
function monthName(d: Date, t: TFn): string {
const months = t("common.months", { returnObjects: true });
if (Array.isArray(months) && typeof months[d.getMonth()] === "string") {
return months[d.getMonth()] as string;
}
return String(d.getMonth() + 1);
}
/**
* Human, day-relative date+time for sessions/logs/history. An event from earlier
* today reads "Sot 10:48", yesterday "Dje 17:33", and anything older a localized
* "17 Qershor 10:48" (month name from the active catalog). Keeps time-of-day on
* every variant — operators care about it within a shift.
*
* `t` supplies the today/yesterday words AND the month names (the appliance browser
* may lack Albanian Intl data, so month names come from the catalog, not Intl).
*/
export function formatRelativeDateTime(iso: string | null, t: TFn): string {
if (!iso) return "—";
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return "—";
const diff = dayDiff(d, new Date());
if (diff === 0) return `${t("common.today")} ${hhmm(d)}`;
if (diff === 1) return `${t("common.yesterday")} ${hhmm(d)}`;
// Older (or future): "17 Qershor 10:48", with the year only if it differs.
const sameYear = d.getFullYear() === new Date().getFullYear();
const month = monthName(d, t);
const date = sameYear ? `${d.getDate()} ${month}` : `${d.getDate()} ${month} ${d.getFullYear()}`;
return `${date} ${hhmm(d)}`;
}
+17 -2
View File
@@ -14,6 +14,22 @@ export const en: Catalog = {
themeDark: "dark", themeDark: "dark",
themeLight: "light", themeLight: "light",
theme: "Theme", theme: "Theme",
today: "Today",
yesterday: "Yesterday",
months: [
"January",
"February",
"March",
"April",
"May",
"June",
"July",
"August",
"September",
"October",
"November",
"December",
],
}, },
auth: { auth: {
title: "Parking System", title: "Parking System",
@@ -83,7 +99,6 @@ export const en: Catalog = {
activeSessions: "Active sessions", activeSessions: "Active sessions",
insideCount: "inside", insideCount: "inside",
noActiveSessions: "No active sessions.", noActiveSessions: "No active sessions.",
inAt: "in",
openPayExit: "Open pay / exit", openPayExit: "Open pay / exit",
openBarrier: "Open barrier", openBarrier: "Open barrier",
openBarrierTitle: "Human-intervention barrier open (audited)", openBarrierTitle: "Human-intervention barrier open (audited)",
@@ -152,7 +167,7 @@ export const en: Catalog = {
"entry.refused.full": "Entry refused — lot full ({{count}}/{{capacity}})", "entry.refused.full": "Entry refused — lot full ({{count}}/{{capacity}})",
"entry.held.noTicket": "Entry held — ticket not printed: {{detail}}", "entry.held.noTicket": "Entry held — ticket not printed: {{detail}}",
"exit.refused.closed": "Exit refused — session already closed", "exit.refused.closed": "Exit refused — session already closed",
"exit.refused.noSession": "Exit refused — no open session for ticket", "exit.refused.noSession": "Exit refused — unknown ticket",
"exit.refused.unpaid": "Exit refused — not paid (take payment first)", "exit.refused.unpaid": "Exit refused — not paid (take payment first)",
"exit.refused.graceExpired": "Exit refused — walk-back grace expired (top-up required)", "exit.refused.graceExpired": "Exit refused — walk-back grace expired (top-up required)",
"exit.open.noBarrier": "Exit recorded, but no exit barrier is configured — open manually", "exit.open.noBarrier": "Exit recorded, but no exit barrier is configured — open manually",
+18 -1
View File
@@ -14,6 +14,24 @@ export const sq = {
themeDark: "errët", themeDark: "errët",
themeLight: "çelët", themeLight: "çelët",
theme: "Tema", theme: "Tema",
today: "Sot",
yesterday: "Dje",
// Month names (index 0 = January) — kept in the catalog because the appliance's
// browser ICU may lack Albanian locale data (Intl falls back to English).
months: [
"Janar",
"Shkurt",
"Mars",
"Prill",
"Maj",
"Qershor",
"Korrik",
"Gusht",
"Shtator",
"Tetor",
"Nëntor",
"Dhjetor",
],
}, },
auth: { auth: {
title: "Sistemi i Parkimit", title: "Sistemi i Parkimit",
@@ -83,7 +101,6 @@ export const sq = {
activeSessions: "Sesionet aktive", activeSessions: "Sesionet aktive",
insideCount: "brenda", insideCount: "brenda",
noActiveSessions: "Asnjë sesion aktiv.", noActiveSessions: "Asnjë sesion aktiv.",
inAt: "në",
openPayExit: "Hap pagesën / daljen", openPayExit: "Hap pagesën / daljen",
openBarrier: "Hap barrierën", openBarrier: "Hap barrierën",
openBarrierTitle: "Hap barrierën manualisht", openBarrierTitle: "Hap barrierën manualisht",
+21 -7
View File
@@ -99,10 +99,17 @@ function LanguageToggle({
user: SessionUser; user: SessionUser;
setUser: (u: SessionUser | null) => void; setUser: (u: SessionUser | null) => void;
}) { }) {
// The ACTIVE language is i18n's own state, not the router-context `user` — the
// latter is captured at route-resolution time and does NOT re-render when we call
// setUser, so reading `user.language` here goes stale after the first switch (the
// highlight froze and the equality guard blocked switching back until a refresh).
// useTranslation() subscribes to i18n's languageChanged, so this stays live.
const { i18n } = useTranslation();
const active = i18n.language as Lang;
async function pick(lang: Lang) { async function pick(lang: Lang) {
if (lang === user.language) return; if (lang === active) return;
setLanguage(lang); // instant UI setLanguage(lang); // instant UI (fires i18n languageChanged → re-render)
setUser({ ...user, language: lang }); setUser({ ...user, language: lang }); // keep context eventually-consistent + persisted state
try { try {
await setLanguagePref(lang); // persist await setLanguagePref(lang); // persist
} catch { } catch {
@@ -117,7 +124,7 @@ function LanguageToggle({
type="button" type="button"
onClick={() => pick(l)} onClick={() => pick(l)}
className={`rounded-term px-1.5 py-0.5 ${ className={`rounded-term px-1.5 py-0.5 ${
user.language === l ? "bg-term-panel-2 text-term-amber" : "text-term-muted hover:text-term-text" active === l ? "bg-term-panel-2 text-term-amber" : "text-term-muted hover:text-term-text"
}`} }`}
> >
{l} {l}
@@ -138,10 +145,17 @@ function ThemeToggle({
setUser: (u: SessionUser | null) => void; setUser: (u: SessionUser | null) => void;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
// Local state for the ACTIVE theme — same reason as LanguageToggle: the router
// context `user` doesn't re-render on setUser, so reading `user.theme` here froze
// the highlight after one switch and blocked toggling back until a refresh. Seed
// from the prop; update optimistically on pick. App's effect keeps the DOM in sync
// with the persisted user on (re)login.
const [active, setActive] = useState<Theme>(user.theme);
async function pick(theme: Theme) { async function pick(theme: Theme) {
if (theme === user.theme) return; if (theme === active) return;
setActive(theme);
applyTheme(theme); // instant UI applyTheme(theme); // instant UI
setUser({ ...user, theme }); setUser({ ...user, theme }); // keep context eventually-consistent + persisted state
try { try {
await setThemePref(theme); // persist await setThemePref(theme); // persist
} catch { } catch {
@@ -156,7 +170,7 @@ function ThemeToggle({
type="button" type="button"
onClick={() => pick(th)} onClick={() => pick(th)}
className={`rounded-term px-1.5 py-0.5 ${ className={`rounded-term px-1.5 py-0.5 ${
user.theme === th ? "bg-term-panel-2 text-term-amber" : "text-term-muted hover:text-term-text" active === th ? "bg-term-panel-2 text-term-amber" : "text-term-muted hover:text-term-text"
}`} }`}
> >
{t(th === "dark" ? "common.themeDark" : "common.themeLight")} {t(th === "dark" ? "common.themeDark" : "common.themeLight")}
+72 -12
View File
@@ -215,13 +215,43 @@ function duration(fromIso: string, toIso: string): string {
return h > 0 ? `${h}h ${m}m` : `${m}m`; return h > 0 ? `${h}h ${m}m` : `${m}m`;
} }
/** Local date+time "YYYY-MM-DD HH:MM" for a receipt row. The host clock is the /** Albanian month names (customer-facing receipts are always Albanian — see
* site's local time (the appliance runs in the site's zone). */ * i18n.md). Indexed by Date.getMonth() (0 = Janar). */
function stamp(iso: string): string { const SQ_MONTHS = [
"Janar",
"Shkurt",
"Mars",
"Prill",
"Maj",
"Qershor",
"Korrik",
"Gusht",
"Shtator",
"Tetor",
"Nëntor",
"Dhjetor",
] as const;
/** Human local date+time for a receipt row, e.g. "19 Qershor 2026 10:48:25". The
* host clock is the site's local time (the appliance runs in the site's zone);
* 24-hour with seconds (Albania uses 24h). Falls back to the raw ISO on a bad date.
* Exported (as formatStampSq) so other server-side printed output — e.g. the shift
* Z-report — shares one Albanian date format. */
export function stamp(iso: string): string {
const d = new Date(iso); const d = new Date(iso);
if (Number.isNaN(d.getTime())) return iso; if (Number.isNaN(d.getTime())) return iso;
const p = (n: number) => String(n).padStart(2, "0"); const p = (n: number) => String(n).padStart(2, "0");
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`; const date = `${d.getDate()} ${SQ_MONTHS[d.getMonth()]} ${d.getFullYear()}`;
const time = `${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;
return `${date} ${time}`;
}
/** Date-only Albanian format "19 Qershor 2026" (for subscription validity dates,
* which are date strings with no time). Passes through a non-date value unchanged. */
function dateOnly(value: string): string {
const d = new Date(value);
if (Number.isNaN(d.getTime())) return value;
return `${d.getDate()} ${SQ_MONTHS[d.getMonth()]} ${d.getFullYear()}`;
} }
/** Build the ESC/POS byte stream for a free-form text report (e.g. shift Z-report). */ /** Build the ESC/POS byte stream for a free-form text report (e.g. shift Z-report). */
@@ -284,7 +314,7 @@ export function renderTicket(data: TicketData): Buffer {
DOUBLE_OFF, DOUBLE_OFF,
BOLD_OFF, BOLD_OFF,
line(), line(),
line(STR.issuedAt(data.issuedAt)), line(STR.issuedAt(stamp(data.issuedAt))),
FEED_AND_CUT, FEED_AND_CUT,
]); ]);
} }
@@ -312,7 +342,9 @@ export function renderSubscriptionCard(data: SubscriptionCardData): Buffer {
]; ];
if (data.holderName) parts.push(line(STR.holder(data.holderName))); if (data.holderName) parts.push(line(STR.holder(data.holderName)));
if (data.validFrom || data.validTo) { if (data.validFrom || data.validTo) {
parts.push(line(STR.validity(data.validFrom ?? "—", data.validTo ?? "—"))); parts.push(
line(STR.validity(data.validFrom ? dateOnly(data.validFrom) : "—", data.validTo ? dateOnly(data.validTo) : "—")),
);
} }
parts.push(FEED_AND_CUT); parts.push(FEED_AND_CUT);
return Buffer.concat(parts); return Buffer.concat(parts);
@@ -377,7 +409,15 @@ export function renderReceipt(data: ReceiptData): Buffer {
return Buffer.concat(parts); return Buffer.concat(parts);
} }
/** Open a TCP socket, write the bytes, wait for flush, then close. */ /** Open a TCP socket, write the bytes, and close GRACEFULLY so the printer reads the
* whole stream before the connection tears down.
*
* Why not write-then-destroy: a Socket.write() callback fires when the data reaches
* the local kernel buffer, NOT when the peer has read it. Calling destroy() at that
* point sends a TCP RST that can truncate the job in flight — the printer then has a
* desynced ESC/POS stream and prints raster garbage (solid black bars / banding).
* Instead we `end(payload)` (write + FIN) and wait for the socket to fully close,
* which only happens after the peer has drained our bytes and the FIN is acked. */
export function sendRaw( export function sendRaw(
host: string, host: string,
port: number, port: number,
@@ -387,17 +427,37 @@ export function sendRaw(
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const sock = new Socket(); const sock = new Socket();
let settled = false; let settled = false;
const done = (err?: Error) => { // True once the payload + FIN have been handed off (flushed locally). After this,
// we've done our part; a slow/absent peer-FIN should NOT fail an already-sent job.
let written = false;
const fail = (err: Error) => {
if (settled) return; if (settled) return;
settled = true; settled = true;
sock.destroy(); sock.destroy();
err ? reject(err) : resolve(); reject(err);
};
const succeed = () => {
if (settled) return;
settled = true;
sock.destroy();
resolve();
}; };
sock.setTimeout(timeoutMs); sock.setTimeout(timeoutMs);
sock.on("timeout", () => done(new Error("timeout"))); // A timeout BEFORE the bytes are out is a real failure; one AFTER (some printers
sock.on("error", done); // never send their FIN, holding the socket open) means the job was delivered —
// succeed rather than reject a ticket that already printed.
sock.on("timeout", () => (written ? succeed() : fail(new Error("timeout"))));
sock.on("error", fail);
// `close` fires after the bytes are flushed AND the connection is fully torn down
// (our FIN sent, peer's FIN received) — the job has been delivered by then.
sock.on("close", (hadError) => (hadError ? undefined : succeed()));
sock.connect(port, host, () => { sock.connect(port, host, () => {
sock.write(payload, (err) => (err ? done(err) : done())); // end() writes the payload then sends FIN — a graceful half-close that lets the
// printer finish reading before the socket closes. No abrupt destroy(). The
// write callback confirms the bytes left our buffer.
sock.end(payload, () => {
written = true;
});
}); });
}); });
} }
+3
View File
@@ -21,6 +21,9 @@ export {
cashinoDriver, cashinoDriver,
} from "./drivers/index.js"; } from "./drivers/index.js";
export { isPrinter, type PrinterRole } from "./drivers/printer-rongta.js"; export { isPrinter, type PrinterRole } from "./drivers/printer-rongta.js";
// Albanian human date/time for printed slips (receipts, tickets, shift Z-report),
// kept in one place so all printed output formats dates identically.
export { stamp as formatStampSq } from "./drivers/printer-escpos.js";
export { export {
orderForRole, orderForRole,
printWithFailover, printWithFailover,