web/admin/time.js
Rama main · réplica de lectura del repositorio original.
/* web/admin/time.js — readable dates in the admin panel.
*
* The server sends ISO-8601 strings ("2026-10-27T10:46:31.435668"); the
* panel printed them as they came. formatWhen() turns one into a Persian
* calendar date and a time ("۵ آبان ۱۴۰۵، ۱۰:۴۶"), and keeps the original
* string for the element's title, so the exact value is a hover away.
*
* The audit log and most admin records are written with the server's
* local clock and no offset; a string without an offset is read as the
* browser's local time, which is the same clock when the panel is opened
* where the server runs. Anything that does not parse as a date (an empty
* value, a placeholder) is returned unchanged, so nothing is ever lost.
*
* Pure (no DOM), so it is testable under Node.
*/
"use strict";
const ISO_LIKE = /^\d{4}-\d{2}-\d{2}([T ]\d{2}:\d{2}(:\d{2}(\.\d+)?)?)?(Z|[+-]\d{2}:?\d{2})?$/;
/**
* Parse an ISO-8601 string, or return null. More than three fractional
* digits (Python's microseconds) are cut to milliseconds first: not every
* browser parses six.
*
* @param {unknown} value
* @returns {Date|null}
*/
export function parseIso(value) {
if (typeof value !== "string") return null;
const s = value.trim();
if (!ISO_LIKE.test(s)) return null;
const d = new Date(s.replace(" ", "T").replace(/(\.\d{3})\d+/, "$1"));
return Number.isNaN(d.getTime()) ? null : d;
}
/**
* A readable Persian date and time for *value*, and the original string.
*
* @param {unknown} value — an ISO-8601 string from the server.
* @param {{ locale?: string, timeZone?: string }} [opts] — for tests.
* @returns {{ text: string, title: string }} `text` is the readable form,
* or the value unchanged when it is not a date; `title` is the original
* string ("" when there was none).
*
* @example
* formatWhen("2026-10-27T10:46:31.435668").text // "۵ آبان ۱۴۰۵، ۱۰:۴۶"
* formatWhen("—").text // "—"
*/
export function formatWhen(value, opts = {}) {
const original = value === null || value === undefined ? "" : String(value);
const d = parseIso(original);
if (!d) return { text: original, title: "" };
const locale = opts.locale || "fa-IR";
const hasTime = /[T ]\d{2}:\d{2}/.test(original);
// A bare date is parsed as midnight UTC; shown in UTC it stays the same
// calendar day wherever the browser is.
const zone = opts.timeZone || (hasTime ? null : "UTC");
const tz = zone ? { timeZone: zone } : {};
const datePart = d.toLocaleDateString(locale, { year: "numeric", month: "long", day: "numeric", ...tz });
if (!hasTime) return { text: datePart, title: original };
const timePart = d.toLocaleTimeString(locale, { hour: "2-digit", minute: "2-digit", hour12: false, ...tz });
return { text: `${datePart}، ${timePart}`, title: original };
}