From 5e1a885dcb7e698dea6646a02ea6b642b310a638 Mon Sep 17 00:00:00 2001 From: Julian Cuni Date: Mon, 6 Jul 2026 15:41:40 +0200 Subject: [PATCH] =?UTF-8?q?feat(web):=20one=20date=20standard=20across=20t?= =?UTF-8?q?he=20UI=20=E2=80=94=20"25=20Qer=2014:30"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dates were a mix: catalog-formatted "25 Qershor 20:01" where screens used formatRelativeDateTime, and browser-locale "7/6/2026, 9:34 AM" in ~20 places that called raw toLocaleString/-Date-/-Time-String. Unified: - common.monthsShort in both catalogs (Jan/Shk/…/Qer/Korr/…/Dhj); formatDate ("25 Qer", year only when not current), formatDateTime ("25 Qer 14:30", optional seconds), formatClock ("HH:mm", 24h) in lib/format.ts. formatRelativeDateTime keeps Sot/Dje and switches its older-dates branch to the same short months. - Every raw toLocale* DATE call swept: shifts X-report line, plan effective dates, sub version labels, drawer today feed, snapshot tooltips, device footer checkedAt, event-detail timestamp (keeps seconds — chain evidence), tariff composer active-since + version sidebar. Number toLocaleString (thousand separators) untouched. The catalogs in this commit also carry the keys for the two follow-up commits (fee breakdown, composer increment labels). Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V --- apps/web/src/DrawerManager.tsx | 4 +-- apps/web/src/ShiftsHistory.tsx | 4 +-- apps/web/src/SubscriptionManager.tsx | 5 +-- apps/web/src/SubscriptionPlansManager.tsx | 3 +- apps/web/src/TariffComposer.tsx | 7 ++-- apps/web/src/lib/format.ts | 44 ++++++++++++++++++++--- apps/web/src/lib/i18n/en.ts | 17 +++++++++ apps/web/src/lib/i18n/sq.ts | 20 ++++++++++- apps/web/src/ui/DeviceFooter.tsx | 3 +- apps/web/src/ui/SnapshotStrip.tsx | 7 ++-- apps/web/src/ui/event-detail.tsx | 4 +-- 11 files changed, 96 insertions(+), 22 deletions(-) diff --git a/apps/web/src/DrawerManager.tsx b/apps/web/src/DrawerManager.tsx index 4b7ec4d..3ddfb4a 100644 --- a/apps/web/src/DrawerManager.tsx +++ b/apps/web/src/DrawerManager.tsx @@ -14,7 +14,7 @@ import { type MovementStatus, type ShiftSummary, } from "./api.js"; -import { formatMoney, formatRelativeDateTime } from "./lib/format.js"; +import { formatClock, formatMoney, formatRelativeDateTime } from "./lib/format.js"; import { Panel } from "./ui/Panel.js"; import type { LedgerEvent } from "@parking/shared"; @@ -223,7 +223,7 @@ function TodayRow({ e }: { e: LedgerEvent }) { const pl = (e.payload ?? {}) as { amountMinor?: number; currency?: string; voucherNo?: string; reason?: string }; const amt = pl.amountMinor ?? 0; const signed = e.type === "cash_out" ? -Math.abs(amt) : Math.abs(amt); - const time = new Date(e.occurredAt).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); + const time = formatClock(e.occurredAt); const label = e.type === "payment" ? `${t("drawer.payment")}${e.identity ? ` · ${e.identity}` : ""}` diff --git a/apps/web/src/ShiftsHistory.tsx b/apps/web/src/ShiftsHistory.tsx index 875eb37..c0feebb 100644 --- a/apps/web/src/ShiftsHistory.tsx +++ b/apps/web/src/ShiftsHistory.tsx @@ -12,7 +12,7 @@ import { type ShiftSummary, type SessionUser, } from "./api.js"; -import { formatMoney, formatDuration, formatRelativeDateTime } from "./lib/format.js"; +import { formatMoney, formatDuration, formatDateTime, formatRelativeDateTime } from "./lib/format.js"; import { CARD_PAYMENTS_ENABLED } from "./lib/features.js"; import { Modal } from "./ui/Modal.js"; import { Spinner } from "./ui/Spinner.js"; @@ -457,7 +457,7 @@ function TakingsModal({ onClose }: { onClose: () => void }) {

{t("common.loading")}

) : (
-
{t("shift.asOf")} {new Date(x.asOf).toLocaleString()}
+
{t("shift.asOf")} {formatDateTime(x.asOf, t)}
diff --git a/apps/web/src/SubscriptionManager.tsx b/apps/web/src/SubscriptionManager.tsx index d7cde0e..db20454 100644 --- a/apps/web/src/SubscriptionManager.tsx +++ b/apps/web/src/SubscriptionManager.tsx @@ -27,6 +27,7 @@ import { } from "./api.js"; import { CARD_PAYMENTS_ENABLED } from "./lib/features.js"; import { Modal } from "./ui/Modal.js"; +import { formatDateTime, type TFn } from "./lib/format.js"; // Subscription admin. Create/edit/revoke/delete subscriptions + their credentials // (card/QR) and bound plates. A SALE is priced by selecting an admin-defined PLAN over @@ -179,9 +180,9 @@ function daysLabel(days: number[] | undefined, t: (k: string) => string): string /** A one-line label for a plan VERSION in the correction picker: effective date + its * timeframe summary (or "24/7" when the version has no window). */ -function versionLabel(v: SubscriptionPlan, t: (k: string) => string): string { +function versionLabel(v: SubscriptionPlan, t: TFn): string { const eff = new Date(v.effectiveFrom); - const date = Number.isNaN(eff.getTime()) ? v.effectiveFrom : eff.toLocaleString(); + const date = Number.isNaN(eff.getTime()) ? v.effectiveFrom : formatDateTime(v.effectiveFrom, t); const tf = v.timeframes; const rules = tf ? `${daysLabel(tf.days, t)} ${hhmm(tf.fromMin)}–${hhmm(tf.toMin)}` : t("subs.allDay"); return `${date} · ${rules}`; diff --git a/apps/web/src/SubscriptionPlansManager.tsx b/apps/web/src/SubscriptionPlansManager.tsx index 9cf36eb..52e900b 100644 --- a/apps/web/src/SubscriptionPlansManager.tsx +++ b/apps/web/src/SubscriptionPlansManager.tsx @@ -14,6 +14,7 @@ import { type SubscriptionPlan, } from "./api.js"; import { Modal } from "./ui/Modal.js"; +import { formatDate } from "./lib/format.js"; import { currencyOptions } from "./lib/currencies.js"; // Admin-only subscription PLAN catalog. Plans are admin-composed, versioned config the @@ -277,7 +278,7 @@ export function SubscriptionPlansManager() { {(p.pricePerPeriodMinor / 100).toLocaleString()} {p.currency} / {t(PERIOD_KEY[p.period])} {timeframesSummary(p.timeframes, t)} - {t("plans.colEffective")}: {new Date(p.effectiveFrom).toLocaleDateString()} + {t("plans.colEffective")}: {formatDate(p.effectiveFrom, t)}
{/* Used by */} diff --git a/apps/web/src/TariffComposer.tsx b/apps/web/src/TariffComposer.tsx index fa6efe8..c008dad 100644 --- a/apps/web/src/TariffComposer.tsx +++ b/apps/web/src/TariffComposer.tsx @@ -2,6 +2,7 @@ import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { ApiError, fetchTariff, publishTariffVersion, type TariffState, type TariffVersion } from "./api.js"; import { TariffEditorForm, emptyForm, formFromActive, formFromVersion, toStructure, type FormState } from "./TariffEditorForm.js"; +import { formatDateTime } from "./lib/format.js"; // Tariff composer — the admin edits + publishes the LIVE rate card. Publishing // creates a new IMMUTABLE version (the active card); old versions are kept so past @@ -77,7 +78,7 @@ export function TariffComposer() {

{state.active.name ? `${state.active.name} — ` : ""} {t("tariff.activeSince", { - date: new Date(state.active.effectiveFrom).toLocaleString(), + date: formatDateTime(state.active.effectiveFrom, t), count: state.versions.length, })}

@@ -126,7 +127,7 @@ export function TariffComposer() { }`} > - {v.name ?? new Date(v.effectiveFrom).toLocaleString()} + {v.name ?? formatDateTime(v.effectiveFrom, t)} {isActive && ( {t("tariff.activeBadge")} @@ -134,7 +135,7 @@ export function TariffComposer() { )} - {v.name ? `${new Date(v.effectiveFrom).toLocaleString()} · ` : ""} + {v.name ? `${formatDateTime(v.effectiveFrom, t)} · ` : ""} {v.currency} diff --git a/apps/web/src/lib/format.ts b/apps/web/src/lib/format.ts index 5170c23..1968160 100644 --- a/apps/web/src/lib/format.ts +++ b/apps/web/src/lib/format.ts @@ -85,6 +85,43 @@ function monthName(d: Date, t: TFn): string { return String(d.getMonth() + 1); } +/** Short month ("Qer", "Korr") from the catalog — the UI-wide date standard + * (2026-07-06): every visible date reads "25 Qer" / "7 Korr 2025", never the + * browser-locale "7/6/2026". Falls back to the full name, then the number. */ +function monthShort(d: Date, t: TFn): string { + const months = t("common.monthsShort", { returnObjects: true }); + if (Array.isArray(months) && typeof months[d.getMonth()] === "string") { + return months[d.getMonth()] as string; + } + return monthName(d, t); +} + +/** "HH:mm" (local, 24h) — the unified time-of-day everywhere ("—" for bad input). */ +export function formatClock(iso: string | null): string { + if (!iso) return "—"; + const d = new Date(iso); + return Number.isNaN(d.getTime()) ? "—" : hhmm(d); +} + +/** "25 Qer" (current year) / "25 Qer 2025" (other years) — the unified DATE. */ +export function formatDate(iso: string | null, t: TFn): string { + if (!iso) return "—"; + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return "—"; + const base = `${d.getDate()} ${monthShort(d, t)}`; + return d.getFullYear() === new Date().getFullYear() ? base : `${base} ${d.getFullYear()}`; +} + +/** "25 Qer 14:30" (+ ":ss" when `seconds`) — the unified absolute DATE+TIME. Use + * formatRelativeDateTime instead where "Sot/Dje" reads better (feeds, history). */ +export function formatDateTime(iso: string | null, t: TFn, opts?: { seconds?: boolean }): string { + if (!iso) return "—"; + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return "—"; + const sec = opts?.seconds ? `:${String(d.getSeconds()).padStart(2, "0")}` : ""; + return `${formatDate(iso, t)} ${hhmm(d)}${sec}`; +} + /** * 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 @@ -101,9 +138,6 @@ export function formatRelativeDateTime(iso: string | null, t: TFn): string { 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)}`; + // Older (or future): "17 Qer 10:48" — the short-month standard, year only if it differs. + return `${formatDate(iso, t)} ${hhmm(d)}`; } diff --git a/apps/web/src/lib/i18n/en.ts b/apps/web/src/lib/i18n/en.ts index 12b4f0e..66b8036 100644 --- a/apps/web/src/lib/i18n/en.ts +++ b/apps/web/src/lib/i18n/en.ts @@ -33,6 +33,7 @@ export const en: Catalog = { "November", "December", ], + monthsShort: ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"], }, auth: { title: "Parking System", @@ -330,6 +331,12 @@ export const en: Catalog = { hoursUnit: "hours", egHours: "e.g. 2", pricePerIncrement: "Price / increment (per hour)", + pricePerHour: "Price / hour", + pricePerN: "Price / {{min}} min", + modeFlatN: "Flat price / {{min}} min", + perHourEquiv: "= {{amount}} / hour", + incrementWarning: + "Careful: the billing increment is {{min}} min — every price below is charged per started {{min}} minutes, NOT per hour.", thereafter: "thereafter (open-ended)", remove: "Remove", addBlock: "+ Add block", @@ -562,6 +569,16 @@ export const en: Catalog = { graceExpires: "Grace expires", curve: "Duration curve", curveHint: "Fee from entry at several durations — see where the daily cap flattens or windows shift.", + bd: { + title: "How the amount is produced", + rounding: "{{raw}} min parked → {{billed}} min billed ({{inc}}-min increments)", + grace: "Free — within the entry grace ({{min}} min)", + package: "window package", + step: "Day {{day}}: stay up to {{hours}}h — total", + stepRepeated: "Day {{day}}: beyond the top tier — full-day total", + cap: "Daily cap {{cap}} applied (day {{day}})", + total: "Total", + }, }, subs: { title: "Subscriptions", diff --git a/apps/web/src/lib/i18n/sq.ts b/apps/web/src/lib/i18n/sq.ts index aadad1b..f19033c 100644 --- a/apps/web/src/lib/i18n/sq.ts +++ b/apps/web/src/lib/i18n/sq.ts @@ -35,6 +35,8 @@ export const sq = { "Nëntor", "Dhjetor", ], + // Short month names — the UI-wide date standard ("25 Qer", "7 Korr"). + monthsShort: ["Jan", "Shk", "Mar", "Pri", "Maj", "Qer", "Korr", "Gush", "Sht", "Tet", "Nën", "Dhj"], }, auth: { title: "Sistemi i Parkimit", @@ -332,7 +334,13 @@ export const sq = { bandDuration: "Kohëzgjatja e brezit", hoursUnit: "orë", egHours: "p.sh. 2", - pricePerIncrement: "Çmimi / interval (orë)", + pricePerIncrement: "Çmimi / interval (min)", + pricePerHour: "Çmimi / orë", + pricePerN: "Çmimi / {{min}} min", + modeFlatN: "Çmim fiks / {{min}} min", + perHourEquiv: "= {{amount}} / orë", + incrementWarning: + "Kujdes: intervali i faturimit është {{min}} min — çdo çmim më poshtë faturohet për çdo {{min}} minuta të filluara, JO për orë.", thereafter: "më pas (i hapur)", remove: "Hiq", addBlock: "+ Shto bllok", @@ -574,6 +582,16 @@ export const sq = { graceExpires: "Afati skadon", curve: "Kurba sipas kohëzgjatjes", curveHint: "Tarifa nga hyrja për disa kohëzgjatje — shih ku rrafshohet kufiri ditor ose ndryshojnë dritaret.", + bd: { + title: "Si prodhohet shuma", + rounding: "{{raw}} min qëndrim → {{billed}} min të faturuara (njësi {{inc}} min)", + grace: "Falas — brenda minutave të hirit ({{min}} min)", + package: "paketë dritareje", + step: "Dita {{day}}: qëndrim deri në {{hours}}h — total", + stepRepeated: "Dita {{day}}: mbi shkallën më të lartë — totali ditor", + cap: "U zbatua kufiri ditor {{cap}} (dita {{day}})", + total: "Totali", + }, }, subs: { title: "Abonimet", diff --git a/apps/web/src/ui/DeviceFooter.tsx b/apps/web/src/ui/DeviceFooter.tsx index 898666e..af1e5e6 100644 --- a/apps/web/src/ui/DeviceFooter.tsx +++ b/apps/web/src/ui/DeviceFooter.tsx @@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { useQuery } from "@tanstack/react-query"; import { fetchDeviceStatus, type DeviceStatus } from "../api.js"; +import { formatClock } from "../lib/format.js"; import { qk } from "../lib/query.js"; import { useLiveStore } from "../lib/live-store.js"; @@ -184,7 +185,7 @@ export function DeviceFooter() {
{d.detail &&
{d.detail}
}
- {t("devices.checkedAt", { time: new Date(d.checkedAt).toLocaleTimeString() })} + {t("devices.checkedAt", { time: formatClock(d.checkedAt) })}
diff --git a/apps/web/src/ui/SnapshotStrip.tsx b/apps/web/src/ui/SnapshotStrip.tsx index d506989..9bc8517 100644 --- a/apps/web/src/ui/SnapshotStrip.tsx +++ b/apps/web/src/ui/SnapshotStrip.tsx @@ -1,5 +1,6 @@ import { useState } from "react"; import { useTranslation } from "react-i18next"; +import { formatDateTime } from "../lib/format.js"; import { useQuery } from "@tanstack/react-query"; import { fetchSnapshots, snapshotImageUrl, type PlateRead } from "../api.js"; @@ -52,7 +53,7 @@ export function SnapshotStrip({ identity }: { identity: string }) { key={`${p.plate}-${p.direction}-${i}`} className="inline-flex items-center gap-1.5 rounded-term border border-term-cyan/40 bg-term-cyan/10 px-2 py-0.5 text-[0.6875rem]" title={`${dirLabel(p.direction)}${p.region ? ` · ${p.region}` : ""}${ - p.at ? ` · ${new Date(p.at).toLocaleString()}` : "" + p.at ? ` · ${formatDateTime(p.at, t)}` : "" }`} > {t("pay.plate")} @@ -72,7 +73,7 @@ export function SnapshotStrip({ identity }: { identity: string }) { type="button" onClick={() => setZoom(s.id)} className="group flex flex-col items-center gap-1 rounded-term border border-term-border bg-term-panel-2 p-1 hover:border-term-amber" - title={`${dirLabel(s.direction)} · ${new Date(s.capturedAt).toLocaleString()}`} + title={`${dirLabel(s.direction)} · ${formatDateTime(s.capturedAt, t)}`} > ⚠ {dirLabel(f.direction)} diff --git a/apps/web/src/ui/event-detail.tsx b/apps/web/src/ui/event-detail.tsx index 3aa2a69..fe734f3 100644 --- a/apps/web/src/ui/event-detail.tsx +++ b/apps/web/src/ui/event-detail.tsx @@ -1,7 +1,7 @@ import { useTranslation } from "react-i18next"; import { type ReactNode } from "react"; import { type LedgerEvent } from "../api.js"; -import { formatMoney } from "../lib/format.js"; +import { formatMoney, formatDateTime } from "../lib/format.js"; import { renderReason } from "../lib/reason.js"; import { Modal } from "./Modal.js"; import { SnapshotStrip } from "./SnapshotStrip.js"; @@ -226,7 +226,7 @@ export function EventDetailModal({ e, onClose }: { e: LedgerEvent; onClose: () = {/* Humanized fields — labelled rows, not raw JSON. Only what applies renders. */}
- {new Date(e.occurredAt).toLocaleString()} + {formatDateTime(e.occurredAt, t, { seconds: true })} #{e.index} {e.direction && {e.direction}} {e.source && {e.source}}