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:
@@ -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}` : ""}`
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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}`;
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)}`;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>}
|
||||
|
||||
Reference in New Issue
Block a user