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
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
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 { FastifyBaseLogger } from "fastify";
|
||||
import type { EventLog } from "./event-log.js";
|
||||
@@ -393,21 +393,23 @@ export class ShiftService {
|
||||
}
|
||||
const cur = r.currency ?? "";
|
||||
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 = [
|
||||
`Operator: ${r.operator}`,
|
||||
`From: ${r.startedAt}`,
|
||||
`To: ${r.endedAt}`,
|
||||
`Operatori: ${r.operator}`,
|
||||
`Nga: ${zStamp(r.startedAt)}`,
|
||||
`Deri: ${zStamp(r.endedAt)}`,
|
||||
"",
|
||||
`Payments: ${r.paymentCount}`,
|
||||
`Cash: ${money(r.cashTotalMinor)} ${cur}`,
|
||||
`Card: ${money(r.cardTotalMinor)} ${cur}`,
|
||||
`Pagesa: ${r.paymentCount}`,
|
||||
`Para në dorë: ${money(r.cashTotalMinor)} ${cur}`,
|
||||
`Kartë: ${money(r.cardTotalMinor)} ${cur}`,
|
||||
"",
|
||||
"-- Drawer --",
|
||||
`Opening float: ${money(r.openingFloatMinor)} ${cur}`,
|
||||
`Cash taken: ${money(r.cashTotalMinor)} ${cur}`,
|
||||
`Cash added: ${money(r.cashAddedMinor)} ${cur}`,
|
||||
`Cash removed: ${money(r.cashRemovedMinor)} ${cur}`,
|
||||
`Expected drawer: ${money(r.expectedDrawerMinor)} ${cur}`,
|
||||
"-- Arka --",
|
||||
`Fillimi (kusur): ${money(r.openingFloatMinor)} ${cur}`,
|
||||
`Para të marra: ${money(r.cashTotalMinor)} ${cur}`,
|
||||
`Para të shtuara: ${money(r.cashAddedMinor)} ${cur}`,
|
||||
`Para të hequra: ${money(r.cashRemovedMinor)} ${cur}`,
|
||||
`Arka e pritur: ${money(r.expectedDrawerMinor)} ${cur}`,
|
||||
];
|
||||
try {
|
||||
await printer.printReport({ title: "RAPORT TURNI", lines });
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { fetchActiveSessions, reopenBarrier, type ActiveSession } from "./api.js";
|
||||
import { qk } from "./lib/query.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";
|
||||
|
||||
// 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">
|
||||
{s.subscription ? `★ ${s.subscriptionHolder ?? t("subs.unnamed")}` : s.identity}
|
||||
</span>
|
||||
<span className="text-term-muted">
|
||||
{t("booth.inAt")} {formatTime(s.enteredAt)}
|
||||
</span>
|
||||
<span className="text-term-muted">{formatRelativeDateTime(s.enteredAt, t)}</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>
|
||||
</button>
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
} from "./api.js";
|
||||
import { qk } from "./lib/query.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";
|
||||
|
||||
// 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 */}
|
||||
<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.duration")}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
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
|
||||
// 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
|
||||
// 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 {
|
||||
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 [open, setOpen] = useState(false);
|
||||
const cur = s.currency;
|
||||
const when = (iso: string) => formatRelativeDateTime(iso, t);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -146,9 +134,9 @@ function ShiftRow({ s, showOperator, colSpan }: { s: ShiftSummary; showOperator:
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
>
|
||||
{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">
|
||||
{fmtDateTime(s.endedAt)}
|
||||
{when(s.endedAt)}
|
||||
<span className="ml-2 text-term-muted">{formatDuration(s.startedAt, s.endedAt)}</span>
|
||||
</td>
|
||||
<td className="px-3 py-1.5 text-right">{s.paymentCount}</td>
|
||||
|
||||
@@ -28,3 +28,58 @@ export function formatTime(iso: string | null): string {
|
||||
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
|
||||
* 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)}`;
|
||||
}
|
||||
|
||||
@@ -14,6 +14,22 @@ export const en: Catalog = {
|
||||
themeDark: "dark",
|
||||
themeLight: "light",
|
||||
theme: "Theme",
|
||||
today: "Today",
|
||||
yesterday: "Yesterday",
|
||||
months: [
|
||||
"January",
|
||||
"February",
|
||||
"March",
|
||||
"April",
|
||||
"May",
|
||||
"June",
|
||||
"July",
|
||||
"August",
|
||||
"September",
|
||||
"October",
|
||||
"November",
|
||||
"December",
|
||||
],
|
||||
},
|
||||
auth: {
|
||||
title: "Parking System",
|
||||
@@ -83,7 +99,6 @@ export const en: Catalog = {
|
||||
activeSessions: "Active sessions",
|
||||
insideCount: "inside",
|
||||
noActiveSessions: "No active sessions.",
|
||||
inAt: "in",
|
||||
openPayExit: "Open pay / exit",
|
||||
openBarrier: "Open barrier",
|
||||
openBarrierTitle: "Human-intervention barrier open (audited)",
|
||||
@@ -152,7 +167,7 @@ export const en: Catalog = {
|
||||
"entry.refused.full": "Entry refused — lot full ({{count}}/{{capacity}})",
|
||||
"entry.held.noTicket": "Entry held — ticket not printed: {{detail}}",
|
||||
"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.graceExpired": "Exit refused — walk-back grace expired (top-up required)",
|
||||
"exit.open.noBarrier": "Exit recorded, but no exit barrier is configured — open manually",
|
||||
|
||||
@@ -14,6 +14,24 @@ export const sq = {
|
||||
themeDark: "errët",
|
||||
themeLight: "çelët",
|
||||
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: {
|
||||
title: "Sistemi i Parkimit",
|
||||
@@ -83,7 +101,6 @@ export const sq = {
|
||||
activeSessions: "Sesionet aktive",
|
||||
insideCount: "brenda",
|
||||
noActiveSessions: "Asnjë sesion aktiv.",
|
||||
inAt: "në",
|
||||
openPayExit: "Hap pagesën / daljen",
|
||||
openBarrier: "Hap barrierën",
|
||||
openBarrierTitle: "Hap barrierën manualisht",
|
||||
|
||||
+21
-7
@@ -99,10 +99,17 @@ function LanguageToggle({
|
||||
user: SessionUser;
|
||||
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) {
|
||||
if (lang === user.language) return;
|
||||
setLanguage(lang); // instant UI
|
||||
setUser({ ...user, language: lang });
|
||||
if (lang === active) return;
|
||||
setLanguage(lang); // instant UI (fires i18n languageChanged → re-render)
|
||||
setUser({ ...user, language: lang }); // keep context eventually-consistent + persisted state
|
||||
try {
|
||||
await setLanguagePref(lang); // persist
|
||||
} catch {
|
||||
@@ -117,7 +124,7 @@ function LanguageToggle({
|
||||
type="button"
|
||||
onClick={() => pick(l)}
|
||||
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}
|
||||
@@ -138,10 +145,17 @@ function ThemeToggle({
|
||||
setUser: (u: SessionUser | null) => void;
|
||||
}) {
|
||||
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) {
|
||||
if (theme === user.theme) return;
|
||||
if (theme === active) return;
|
||||
setActive(theme);
|
||||
applyTheme(theme); // instant UI
|
||||
setUser({ ...user, theme });
|
||||
setUser({ ...user, theme }); // keep context eventually-consistent + persisted state
|
||||
try {
|
||||
await setThemePref(theme); // persist
|
||||
} catch {
|
||||
@@ -156,7 +170,7 @@ function ThemeToggle({
|
||||
type="button"
|
||||
onClick={() => pick(th)}
|
||||
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")}
|
||||
|
||||
@@ -215,13 +215,43 @@ function duration(fromIso: string, toIso: string): string {
|
||||
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
|
||||
* site's local time (the appliance runs in the site's zone). */
|
||||
function stamp(iso: string): string {
|
||||
/** Albanian month names (customer-facing receipts are always Albanian — see
|
||||
* i18n.md). Indexed by Date.getMonth() (0 = Janar). */
|
||||
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);
|
||||
if (Number.isNaN(d.getTime())) return iso;
|
||||
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). */
|
||||
@@ -284,7 +314,7 @@ export function renderTicket(data: TicketData): Buffer {
|
||||
DOUBLE_OFF,
|
||||
BOLD_OFF,
|
||||
line(),
|
||||
line(STR.issuedAt(data.issuedAt)),
|
||||
line(STR.issuedAt(stamp(data.issuedAt))),
|
||||
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.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);
|
||||
return Buffer.concat(parts);
|
||||
|
||||
@@ -21,6 +21,9 @@ export {
|
||||
cashinoDriver,
|
||||
} from "./drivers/index.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 {
|
||||
orderForRole,
|
||||
printWithFailover,
|
||||
|
||||
Reference in New Issue
Block a user