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")}
) : ({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() {