thread-master/apps/web/src/lib/time.ts

144 lines
5.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { loadUiPrefs } from "./i18n/prefs";
import { translate } from "./i18n/messages";
import type { AppLocale } from "./i18n/types";
/** Unix nanoseconds (UTC), matching product contract. */
export function nowUnixNano(): number {
return Date.now() * 1_000_000;
}
function currentLocale(): AppLocale {
return loadUiPrefs().locale;
}
function t(key: string, params?: Record<string, string | number>): string {
return translate(currentLocale(), key, params);
}
/** 後端可能回 nsmssec統一成 unix nanoseconds顯示用毫秒精度即可 */
export function normalizeUnixNano(raw: number | string | null | undefined): number | null {
if (raw === null || raw === undefined || raw === "") return null;
let n: number;
if (typeof raw === "string") {
const trimmed = raw.trim();
if (!trimmed) return null;
// 大整數字串:用 BigInt 收到毫秒再還原 ns避免 Number 精準度問題
try {
const bi = BigInt(trimmed);
if (bi <= 0n) return null;
// sec ~1e9, ms ~1e12, ns ~1e18
if (bi < 1_000_000_000_000n) return Number(bi) * 1_000_000_000; // sec
if (bi < 1_000_000_000_000_000n) return Number(bi) * 1_000_000; // ms
return Number(bi / 1_000_000n) * 1_000_000; // ns → ms 精度
} catch {
n = Number(trimmed);
}
} else {
n = raw;
}
if (!Number.isFinite(n) || n <= 0) return null;
if (n < 1e12) return Math.round(n * 1e9); // seconds
if (n < 1e15) return Math.round(n * 1e6); // milliseconds
return n; // nanoseconds
}
export function formatLocalDateTime(nano: number | null | undefined): string {
const n = normalizeUnixNano(nano ?? null);
if (!n) return "—";
const ms = Math.floor(n / 1_000_000);
const locale = currentLocale() === "en" ? "en-US" : "zh-TW";
return new Date(ms).toLocaleString(locale, {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
});
}
export type SessionExpiryKind = "unknown" | "ok" | "soon" | "expired";
/** soon = 48h 內到期 */
export function sessionExpiryKind(
expiresAt: number | null | undefined,
now = nowUnixNano(),
): SessionExpiryKind {
const exp = normalizeUnixNano(expiresAt ?? null);
if (exp == null || exp <= 0) return "unknown";
if (exp <= now) return "expired";
const soonNs = 48 * 60 * 60 * 1_000_000_000;
if (exp - now <= soonNs) return "soon";
return "ok";
}
export function formatRelativeFromNow(
nano: number | null | undefined,
now = nowUnixNano(),
): string {
const n = normalizeUnixNano(nano ?? null);
if (n == null || n <= 0) return "—";
const diffMs = Math.floor((n - now) / 1_000_000);
const abs = Math.abs(diffMs);
const min = Math.round(abs / 60_000);
const hour = Math.round(abs / 3_600_000);
const day = Math.round(abs / 86_400_000);
let span: string;
if (min < 60) span = t("time.min", { n: Math.max(1, min) });
else if (hour < 48) span = t("time.hour", { n: hour });
else span = t("time.day", { n: day });
if (diffMs < 0) return t("time.expired", { span });
return t("time.remaining", { span });
}
/** 過去時間通知列表用剛剛N 分前) */
export function formatTimeAgo(nano: number | null | undefined, now = nowUnixNano()): string {
// 這裡是唯一漏掉正規化的時間格式化函式:後端若回秒或毫秒,未換算的數值會遠小於 now
// 算出來就變成「56 年前」。
const n = normalizeUnixNano(nano ?? null);
if (n == null || n <= 0) return "—";
const diffMs = Math.floor((now - n) / 1_000_000);
if (diffMs < 45_000) return t("time.justNow");
const min = Math.floor(diffMs / 60_000);
if (min < 60) return t("time.minAgo", { n: min });
const hour = Math.floor(diffMs / 3_600_000);
if (hour < 48) return t("time.hourAgo", { n: hour });
const day = Math.floor(diffMs / 86_400_000);
if (day < 14) return t("time.dayAgo", { n: day });
return formatLocalDateTime(nano);
}
export function formatSessionExpiry(expiresAt: number | null | undefined): {
kind: SessionExpiryKind;
absolute: string;
relative: string;
label: string;
} {
const exp = normalizeUnixNano(expiresAt ?? null);
const kind = sessionExpiryKind(exp);
const absolute = formatLocalDateTime(exp);
const relative = formatRelativeFromNow(exp);
const label =
kind === "unknown"
? t("time.sessionUnknown")
: kind === "expired"
? t("time.sessionExpired", { absolute })
: kind === "soon"
? t("time.sessionSoon", { relative, absolute })
: t("time.sessionOk", { relative, absolute });
return { kind, absolute, relative, label };
}
export function fromDatetimeLocalValue(value: string): number {
if (!value) return nowUnixNano();
const ms = new Date(value).getTime();
if (Number.isNaN(ms)) return nowUnixNano();
return ms * 1_000_000;
}
export function toDatetimeLocalValue(nano: number | null | undefined): string {
if (!nano) return "";
const d = new Date(Math.floor(nano / 1_000_000));
const pad = (n: number) => String(n).padStart(2, "0");
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
}