diff --git a/apps/server/src/shift-service.ts b/apps/server/src/shift-service.ts
index 9a1d465..ec4e2a3 100644
--- a/apps/server/src/shift-service.ts
+++ b/apps/server/src/shift-service.ts
@@ -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 });
diff --git a/apps/web/src/ActiveSessions.tsx b/apps/web/src/ActiveSessions.tsx
index 282e0b4..4ad3544 100644
--- a/apps/web/src/ActiveSessions.tsx
+++ b/apps/web/src/ActiveSessions.tsx
@@ -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
{s.subscription ? `★ ${s.subscriptionHolder ?? t("subs.unnamed")}` : s.identity}
-
- {t("booth.inAt")} {formatTime(s.enteredAt)}
-
+ {formatRelativeDateTime(s.enteredAt, t)}
{formatDuration(s.enteredAt, new Date().toISOString())}
{t(badge.key)}
diff --git a/apps/web/src/BoothPayModal.tsx b/apps/web/src/BoothPayModal.tsx
index 11f5492..a4f0afd 100644
--- a/apps/web/src/BoothPayModal.tsx
+++ b/apps/web/src/BoothPayModal.tsx
@@ -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 */}
-
+
formatRelativeDateTime(iso, t);
return (
<>
@@ -146,9 +134,9 @@ function ShiftRow({ s, showOperator, colSpan }: { s: ShiftSummary; showOperator:
onClick={() => setOpen((o) => !o)}
>
{showOperator && | {s.operator} | }
- {fmtDateTime(s.startedAt)} |
+ {when(s.startedAt)} |
- {fmtDateTime(s.endedAt)}
+ {when(s.endedAt)}
{formatDuration(s.startedAt, s.endedAt)}
|
{s.paymentCount} |
diff --git a/apps/web/src/lib/format.ts b/apps/web/src/lib/format.ts
index 8fceab5..57cec76 100644
--- a/apps/web/src/lib/format.ts
+++ b/apps/web/src/lib/format.ts
@@ -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)}`;
+}
diff --git a/apps/web/src/lib/i18n/en.ts b/apps/web/src/lib/i18n/en.ts
index dbbd963..4ceacf3 100644
--- a/apps/web/src/lib/i18n/en.ts
+++ b/apps/web/src/lib/i18n/en.ts
@@ -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",
diff --git a/apps/web/src/lib/i18n/sq.ts b/apps/web/src/lib/i18n/sq.ts
index 305de00..02d5223 100644
--- a/apps/web/src/lib/i18n/sq.ts
+++ b/apps/web/src/lib/i18n/sq.ts
@@ -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",
diff --git a/apps/web/src/router.tsx b/apps/web/src/router.tsx
index 4b36c21..570952f 100644
--- a/apps/web/src/router.tsx
+++ b/apps/web/src/router.tsx
@@ -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(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")}
diff --git a/packages/devices/src/drivers/printer-escpos.ts b/packages/devices/src/drivers/printer-escpos.ts
index 0b3bb97..7661300 100644
--- a/packages/devices/src/drivers/printer-escpos.ts
+++ b/packages/devices/src/drivers/printer-escpos.ts
@@ -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);
diff --git a/packages/devices/src/index.ts b/packages/devices/src/index.ts
index 4885a91..56001dd 100644
--- a/packages/devices/src/index.ts
+++ b/packages/devices/src/index.ts
@@ -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,