fix(booth-pay): entry/exit timestamps read alike (Sot 19:25:44)
The pay modal rendered entry via formatRelativeDateTime (relative day, no
seconds → "Sot 19:25") and exit/now via the legacy formatTime (raw
HH:MM:SS, no day → "19:25:44") — inconsistent on both day context and
seconds. Added a { seconds } option to formatRelativeDateTime and routed
all four call sites (entry, exit, live now, alreadyClosed toast) through
it, so every row reads "Sot 19:25:44". Removed formatTime — the last raw
toTimeString() helper and the source of the mismatch; BoothPayModal was
its only caller.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
This commit is contained in:
@@ -18,7 +18,7 @@ import {
|
|||||||
import { rootRoute } from "./router.js";
|
import { rootRoute } from "./router.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, formatRelativeDateTime } from "./lib/format.js";
|
import { formatDuration, formatMoney, formatRelativeDateTime } from "./lib/format.js";
|
||||||
import { CARD_PAYMENTS_ENABLED } from "./lib/features.js";
|
import { CARD_PAYMENTS_ENABLED } from "./lib/features.js";
|
||||||
import { SnapshotStrip } from "./ui/SnapshotStrip.js";
|
import { SnapshotStrip } from "./ui/SnapshotStrip.js";
|
||||||
import { Spinner } from "./ui/Spinner.js";
|
import { Spinner } from "./ui/Spinner.js";
|
||||||
@@ -310,12 +310,12 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
|
|||||||
// figures, and the snapshot strip read-only. No tender / voucher / open here.
|
// figures, and the snapshot strip read-only. No tender / voucher / open here.
|
||||||
<>
|
<>
|
||||||
<div className="rounded-term border border-term-amber px-3 py-2 text-term-amber">
|
<div className="rounded-term border border-term-amber px-3 py-2 text-term-amber">
|
||||||
{t("pay.alreadyClosed", { time: formatTime(s.exitedAt) })}
|
{t("pay.alreadyClosed", { time: formatRelativeDateTime(s.exitedAt, t, { seconds: true }) })}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<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={formatRelativeDateTime(s.enteredAt, t)} />
|
<Row label={t("pay.entry")} value={formatRelativeDateTime(s.enteredAt, t, { seconds: true })} />
|
||||||
<Row label={t("pay.exit")} value={formatTime(s.exitedAt)} />
|
<Row label={t("pay.exit")} value={formatRelativeDateTime(s.exitedAt, t, { seconds: true })} />
|
||||||
<Row
|
<Row
|
||||||
label={t("pay.duration")}
|
label={t("pay.duration")}
|
||||||
value={
|
value={
|
||||||
@@ -335,11 +335,15 @@ 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={formatRelativeDateTime(s.enteredAt, t)} />
|
<Row label={t("pay.entry")} value={formatRelativeDateTime(s.enteredAt, t, { seconds: true })} />
|
||||||
{/* Closed-within-grace shows the recorded EXIT; an open session shows now. */}
|
{/* Closed-within-grace shows the recorded EXIT; an open session shows now. */}
|
||||||
<Row
|
<Row
|
||||||
label={closedWithinGrace ? t("pay.exit") : t("pay.now")}
|
label={closedWithinGrace ? t("pay.exit") : t("pay.now")}
|
||||||
value={closedWithinGrace ? formatTime(s.exitedAt) : formatTime(new Date().toISOString())}
|
value={formatRelativeDateTime(
|
||||||
|
closedWithinGrace ? s.exitedAt : new Date().toISOString(),
|
||||||
|
t,
|
||||||
|
{ seconds: true },
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
<Row
|
<Row
|
||||||
label={t("pay.duration")}
|
label={t("pay.duration")}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { formatMoney, formatDuration, formatTime, formatRelativeDateTime, type TFn } from "./format.js";
|
import { formatMoney, formatDuration, formatRelativeDateTime, type TFn } from "./format.js";
|
||||||
|
|
||||||
// The booth's display formatters. Money is integer MINOR units (never a float, matching
|
// The booth's display formatters. Money is integer MINOR units (never a float, matching
|
||||||
// the ledger/tariff model); duration is whole minutes; relative dates drive the session/
|
// the ledger/tariff model); duration is whole minutes; relative dates drive the session/
|
||||||
@@ -35,16 +35,6 @@ describe("formatDuration", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("formatTime", () => {
|
|
||||||
it("returns an em dash for null/invalid", () => {
|
|
||||||
expect(formatTime(null)).toBe("—");
|
|
||||||
expect(formatTime("not-a-date")).toBe("—");
|
|
||||||
});
|
|
||||||
it("renders HH:MM:SS local time", () => {
|
|
||||||
expect(formatTime("2026-06-21T10:48:25.000Z")).toMatch(/^\d{2}:\d{2}:\d{2}$/);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("formatRelativeDateTime", () => {
|
describe("formatRelativeDateTime", () => {
|
||||||
// A tiny fake t(): today/yesterday words + the month-name array.
|
// A tiny fake t(): today/yesterday words + the month-name array.
|
||||||
const months = ["Jan","Shkurt","Mars","Prill","Maj","Qershor","Korrik","Gusht","Sht","Tet","Nën","Dhj"];
|
const months = ["Jan","Shkurt","Mars","Prill","Maj","Qershor","Korrik","Gusht","Sht","Tet","Nën","Dhj"];
|
||||||
@@ -61,6 +51,12 @@ describe("formatRelativeDateTime", () => {
|
|||||||
expect(formatRelativeDateTime(now.toISOString(), t)).toMatch(/^Sot \d{2}:\d{2}$/);
|
expect(formatRelativeDateTime(now.toISOString(), t)).toMatch(/^Sot \d{2}:\d{2}$/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("appends :ss with the seconds option (entry/exit rows read alike)", () => {
|
||||||
|
const now = new Date();
|
||||||
|
now.setHours(19, 25, 44, 0);
|
||||||
|
expect(formatRelativeDateTime(now.toISOString(), t, { seconds: true })).toMatch(/^Sot \d{2}:\d{2}:44$/);
|
||||||
|
});
|
||||||
|
|
||||||
it("labels yesterday with the localized word", () => {
|
it("labels yesterday with the localized word", () => {
|
||||||
const y = new Date();
|
const y = new Date();
|
||||||
y.setDate(y.getDate() - 1);
|
y.setDate(y.getDate() - 1);
|
||||||
|
|||||||
+13
-16
@@ -46,13 +46,6 @@ export function formatMinutes(mins: number): string {
|
|||||||
return h > 0 ? `${h}h ${m % 60}m` : `${m}m`;
|
return h > 0 ? `${h}h ${m % 60}m` : `${m}m`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Local time-of-day HH:MM:SS from an ISO string. */
|
|
||||||
export function formatTime(iso: string | null): string {
|
|
||||||
if (!iso) return "—";
|
|
||||||
const d = new Date(iso);
|
|
||||||
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
|
/** 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). */
|
* before ref, etc. Compares date parts only (ignores time-of-day). */
|
||||||
function dayDiff(d: Date, ref: Date): number {
|
function dayDiff(d: Date, ref: Date): number {
|
||||||
@@ -61,10 +54,11 @@ function dayDiff(d: Date, ref: Date): number {
|
|||||||
return Math.round((b.getTime() - a.getTime()) / 86_400_000);
|
return Math.round((b.getTime() - a.getTime()) / 86_400_000);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** HH:MM (local, 24h) for the relative-day labels. */
|
/** HH:MM (local, 24h) for the relative-day labels; ":ss" appended when `seconds`. */
|
||||||
function hhmm(d: Date): string {
|
function hhmm(d: Date, seconds = false): string {
|
||||||
const p = (n: number) => String(n).padStart(2, "0");
|
const p = (n: number) => String(n).padStart(2, "0");
|
||||||
return `${p(d.getHours())}:${p(d.getMinutes())}`;
|
const base = `${p(d.getHours())}:${p(d.getMinutes())}`;
|
||||||
|
return seconds ? `${base}:${p(d.getSeconds())}` : base;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Minimal shape of i18next's `t` that we rely on: a string lookup, plus the
|
/** Minimal shape of i18next's `t` that we rely on: a string lookup, plus the
|
||||||
@@ -118,8 +112,7 @@ export function formatDateTime(iso: string | null, t: TFn, opts?: { seconds?: bo
|
|||||||
if (!iso) return "—";
|
if (!iso) return "—";
|
||||||
const d = new Date(iso);
|
const d = new Date(iso);
|
||||||
if (Number.isNaN(d.getTime())) return "—";
|
if (Number.isNaN(d.getTime())) return "—";
|
||||||
const sec = opts?.seconds ? `:${String(d.getSeconds()).padStart(2, "0")}` : "";
|
return `${formatDate(iso, t)} ${hhmm(d, opts?.seconds)}`;
|
||||||
return `${formatDate(iso, t)} ${hhmm(d)}${sec}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -130,14 +123,18 @@ export function formatDateTime(iso: string | null, t: TFn, opts?: { seconds?: bo
|
|||||||
*
|
*
|
||||||
* `t` supplies the today/yesterday words AND the month names (the appliance browser
|
* `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).
|
* may lack Albanian Intl data, so month names come from the catalog, not Intl).
|
||||||
|
*
|
||||||
|
* `seconds` appends ":ss" — use it where a timestamp sits next to another that shows
|
||||||
|
* seconds (e.g. the booth pay modal's entry vs. exit rows), so the two read alike.
|
||||||
*/
|
*/
|
||||||
export function formatRelativeDateTime(iso: string | null, t: TFn): string {
|
export function formatRelativeDateTime(iso: string | null, t: TFn, opts?: { seconds?: boolean }): string {
|
||||||
if (!iso) return "—";
|
if (!iso) return "—";
|
||||||
const d = new Date(iso);
|
const d = new Date(iso);
|
||||||
if (Number.isNaN(d.getTime())) return "—";
|
if (Number.isNaN(d.getTime())) return "—";
|
||||||
|
const time = hhmm(d, opts?.seconds);
|
||||||
const diff = dayDiff(d, new Date());
|
const diff = dayDiff(d, new Date());
|
||||||
if (diff === 0) return `${t("common.today")} ${hhmm(d)}`;
|
if (diff === 0) return `${t("common.today")} ${time}`;
|
||||||
if (diff === 1) return `${t("common.yesterday")} ${hhmm(d)}`;
|
if (diff === 1) return `${t("common.yesterday")} ${time}`;
|
||||||
// Older (or future): "17 Qer 10:48" — the short-month standard, year only if it differs.
|
// Older (or future): "17 Qer 10:48" — the short-month standard, year only if it differs.
|
||||||
return `${formatDate(iso, t)} ${hhmm(d)}`;
|
return `${formatDate(iso, t)} ${time}`;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user