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,