2026-07-10 05:10:31 +00:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-13 01:15:30 +00:00
|
|
|
|
/** 後端可能回 ns/ms/sec;統一成 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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-10 05:10:31 +00:00
|
|
|
|
export function formatLocalDateTime(nano: number | null | undefined): string {
|
2026-07-13 01:15:30 +00:00
|
|
|
|
const n = normalizeUnixNano(nano ?? null);
|
|
|
|
|
|
if (!n) return "—";
|
|
|
|
|
|
const ms = Math.floor(n / 1_000_000);
|
2026-07-10 05:10:31 +00:00
|
|
|
|
const locale = currentLocale() === "en" ? "en-US" : "zh-TW";
|
|
|
|
|
|
return new Date(ms).toLocaleString(locale, {
|
2026-07-13 01:15:30 +00:00
|
|
|
|
year: "numeric",
|
2026-07-10 05:10:31 +00:00
|
|
|
|
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 {
|
2026-07-13 01:15:30 +00:00
|
|
|
|
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";
|
2026-07-10 05:10:31 +00:00
|
|
|
|
return "ok";
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export function formatRelativeFromNow(
|
|
|
|
|
|
nano: number | null | undefined,
|
|
|
|
|
|
now = nowUnixNano(),
|
|
|
|
|
|
): string {
|
2026-07-13 01:15:30 +00:00
|
|
|
|
const n = normalizeUnixNano(nano ?? null);
|
|
|
|
|
|
if (n == null || n <= 0) return "—";
|
|
|
|
|
|
const diffMs = Math.floor((n - now) / 1_000_000);
|
2026-07-10 05:10:31 +00:00
|
|
|
|
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 {
|
2026-07-28 06:33:51 +00:00
|
|
|
|
// 這裡是唯一漏掉正規化的時間格式化函式:後端若回秒或毫秒,未換算的數值會遠小於 now,
|
|
|
|
|
|
// 算出來就變成「56 年前」。
|
|
|
|
|
|
const n = normalizeUnixNano(nano ?? null);
|
|
|
|
|
|
if (n == null || n <= 0) return "—";
|
|
|
|
|
|
const diffMs = Math.floor((now - n) / 1_000_000);
|
2026-07-10 05:10:31 +00:00
|
|
|
|
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;
|
|
|
|
|
|
} {
|
2026-07-13 01:15:30 +00:00
|
|
|
|
const exp = normalizeUnixNano(expiresAt ?? null);
|
|
|
|
|
|
const kind = sessionExpiryKind(exp);
|
|
|
|
|
|
const absolute = formatLocalDateTime(exp);
|
|
|
|
|
|
const relative = formatRelativeFromNow(exp);
|
2026-07-10 05:10:31 +00:00
|
|
|
|
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())}`;
|
|
|
|
|
|
}
|