feat(web): one date standard across the UI — "25 Qer 14:30"

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
This commit is contained in:
2026-07-06 15:41:40 +02:00
parent 6cf3492bff
commit 5e1a885dcb
11 changed files with 96 additions and 22 deletions
+2 -2
View File
@@ -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}` : ""}`
+2 -2
View File
@@ -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 }) {
<p className="text-[0.75rem] text-term-muted">{t("common.loading")}</p>
) : (
<div className="text-[0.8125rem] tabular-nums">
<div className="text-term-muted">{t("shift.asOf")} {new Date(x.asOf).toLocaleString()}</div>
<div className="text-term-muted">{t("shift.asOf")} {formatDateTime(x.asOf, t)}</div>
<div className="mt-1 grid grid-cols-2 gap-x-6 gap-y-0.5">
<Figure label={t("shift.payments")} value={String(x.paymentCount)} />
<span />
+3 -2
View File
@@ -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}`;
+2 -1
View File
@@ -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])}
</span>
<span>{timeframesSummary(p.timeframes, t)}</span>
<span>{t("plans.colEffective")}: {new Date(p.effectiveFrom).toLocaleDateString()}</span>
<span>{t("plans.colEffective")}: {formatDate(p.effectiveFrom, t)}</span>
</div>
{/* Used by */}
+4 -3
View File
@@ -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() {
<p className="mb-4 text-[0.75rem] text-term-muted">
{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,
})}
</p>
@@ -126,7 +127,7 @@ export function TariffComposer() {
}`}
>
<span className="flex items-center gap-2 font-semibold">
{v.name ?? new Date(v.effectiveFrom).toLocaleString()}
{v.name ?? formatDateTime(v.effectiveFrom, t)}
{isActive && (
<span className="rounded border border-term-green px-1 text-[0.625rem] uppercase text-term-green">
{t("tariff.activeBadge")}
@@ -134,7 +135,7 @@ export function TariffComposer() {
)}
</span>
<span className="block text-[0.6875rem] text-term-muted">
{v.name ? `${new Date(v.effectiveFrom).toLocaleString()} · ` : ""}
{v.name ? `${formatDateTime(v.effectiveFrom, t)} · ` : ""}
{v.currency}
</span>
</button>
+39 -5
View File
@@ -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)}`;
}
+17
View File
@@ -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",
+19 -1
View File
@@ -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",
+2 -1
View File
@@ -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() {
</div>
{d.detail && <div className="mt-0.5 break-words text-[0.6875rem] text-term-muted">{d.detail}</div>}
<div className="mt-0.5 text-[0.625rem] tabular-nums text-term-muted/70">
{t("devices.checkedAt", { time: new Date(d.checkedAt).toLocaleTimeString() })}
{t("devices.checkedAt", { time: formatClock(d.checkedAt) })}
</div>
</div>
</li>
+4 -3
View File
@@ -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)}` : ""
}`}
>
<span className="text-[0.5625rem] uppercase tracking-wider text-term-muted">{t("pay.plate")}</span>
@@ -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)}`}
>
<img
src={snapshotImageUrl(s.id)}
@@ -97,7 +98,7 @@ export function SnapshotStrip({ identity }: { identity: string }) {
<div
key={`fail-${f.direction ?? "both"}-${i}`}
className="flex h-[6.75rem] w-28 flex-col items-center justify-center gap-1 rounded-term border border-dashed border-term-amber/60 bg-term-amber/5 p-1 text-center"
title={`${dirLabel(f.direction)} · ${f.error}${f.occurredAt ? ` · ${new Date(f.occurredAt).toLocaleString()}` : ""}`}
title={`${dirLabel(f.direction)} · ${f.error}${f.occurredAt ? ` · ${formatDateTime(f.occurredAt, t)}` : ""}`}
>
<span className="text-lg leading-none text-term-amber">⚠</span>
<span className="text-[0.5625rem] uppercase tracking-wider text-term-amber">{dirLabel(f.direction)}</span>
+2 -2
View File
@@ -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. */}
<div>
<DetailRow label={t("booth.edTime")}>{new Date(e.occurredAt).toLocaleString()}</DetailRow>
<DetailRow label={t("booth.edTime")}>{formatDateTime(e.occurredAt, t, { seconds: true })}</DetailRow>
<DetailRow label={t("booth.edIndex")}>#{e.index}</DetailRow>
{e.direction && <DetailRow label={t("booth.edDirection")}>{e.direction}</DetailRow>}
{e.source && <DetailRow label={t("booth.edSource")}>{e.source}</DetailRow>}