blob: ec393d83ea3369f1f74fa0c99853ecd8ff349e26 [file]
/**
* PR-RELATIVE-TIME-0: a single, locale-aware relative-time formatter
* shared by every Maka surface. The existing `formatRelativeTimestamp`
* sat inside `packages/ui/src/components.tsx` as a private helper,
* which meant it could not be unit-tested and could not be reused by
* sidebar / settings panels.
*
* The formatter is pure (takes an optional `now` so tests do not have
* to monkey-patch `Date.now`). Buckets are intentionally narrow at the
* short end (second / minute / hour) so users see "刚刚" become
* "1 分钟前" promptly, then widen to days. Anything older than ~7 days
* falls back to an absolute date so we do not produce misleadingly
* round numbers like "5 个月前".
*
* The threshold is a deliberate divergence from the previous
* implementation, which used `.format(-Math.round(diffHours / 24), 'day')`
* for ALL timestamps older than a day and produced things like
* "300 天前" for messages a year old. That was strictly less useful than
* the locale date string.
*/
import { uiLocaleToIntlLocale, type UiLocale } from './ui-locale.js';
/** Maximum age (ms) that still gets a relative bucket. Older → absolute. */
const RELATIVE_HORIZON_MS = 7 * 24 * 60 * 60 * 1000;
let cachedRelativeFormat: Intl.RelativeTimeFormat | null = null;
let cachedAbsoluteFormat: Intl.DateTimeFormat | null = null;
let cachedLocale: string | null = null;
function getRelativeFormat(uiLocale: UiLocale): Intl.RelativeTimeFormat {
const locale = uiLocaleToIntlLocale(uiLocale);
if (!cachedRelativeFormat || cachedLocale !== locale) {
cachedRelativeFormat = new Intl.RelativeTimeFormat(locale, { numeric: 'auto' });
cachedAbsoluteFormat = null;
cachedLocale = locale;
}
return cachedRelativeFormat;
}
function getAbsoluteFormat(uiLocale: UiLocale): Intl.DateTimeFormat {
const locale = uiLocaleToIntlLocale(uiLocale);
if (!cachedAbsoluteFormat || cachedLocale !== locale) {
cachedAbsoluteFormat = new Intl.DateTimeFormat(locale, {
dateStyle: 'medium',
timeStyle: 'short',
});
cachedRelativeFormat = null;
cachedLocale = locale;
}
return cachedAbsoluteFormat;
}
/**
* Returns a localized relative label for `ts` (e.g. "1 分钟前", "1 hour ago")
* when within `RELATIVE_HORIZON_MS`, otherwise the absolute date string.
*
* `now` is injectable so tests can pin a deterministic clock. Future
* timestamps (`ts > now`) are clamped to the smallest "刚刚" bucket — we
* don't want sidebar rows showing "in 2 minutes" when a tab's clock
* drifts.
*/
export function formatRelativeTimestamp(
ts: number,
now: number = Date.now(),
locale: UiLocale = 'zh',
): string {
const diffMs = now - ts;
if (diffMs < 0) {
// Clock skew or future-dated record. Snap to "刚刚".
return getRelativeFormat(locale).format(-1, 'second');
}
if (diffMs > RELATIVE_HORIZON_MS) {
return getAbsoluteFormat(locale).format(new Date(ts));
}
const diffSeconds = Math.round(diffMs / 1000);
if (diffSeconds < 60) {
// Clamp to >=1 so we never produce "0 seconds ago".
return getRelativeFormat(locale).format(-Math.max(1, diffSeconds), 'second');
}
const diffMinutes = Math.round(diffSeconds / 60);
if (diffMinutes < 60) return getRelativeFormat(locale).format(-diffMinutes, 'minute');
const diffHours = Math.round(diffMinutes / 60);
if (diffHours < 24) return getRelativeFormat(locale).format(-diffHours, 'hour');
const diffDays = Math.round(diffHours / 24);
return getRelativeFormat(locale).format(-diffDays, 'day');
}
let cachedCompactSameYearFormat: Intl.DateTimeFormat | null = null;
let cachedCompactOtherYearFormat: Intl.DateTimeFormat | null = null;
let cachedCompactLocale: string | null = null;
function getCompactFormats(uiLocale: UiLocale): {
sameYear: Intl.DateTimeFormat;
otherYear: Intl.DateTimeFormat;
} {
const locale = uiLocaleToIntlLocale(uiLocale);
if (
!cachedCompactSameYearFormat ||
!cachedCompactOtherYearFormat ||
cachedCompactLocale !== locale
) {
cachedCompactSameYearFormat = new Intl.DateTimeFormat(locale, {
month: 'short',
day: 'numeric',
});
cachedCompactOtherYearFormat = new Intl.DateTimeFormat(locale, {
year: 'numeric',
month: 'short',
day: 'numeric',
});
cachedCompactLocale = locale;
}
return { sameYear: cachedCompactSameYearFormat, otherYear: cachedCompactOtherYearFormat };
}
/**
* Compact variant for space-starved rows (sidebar session list): same
* relative buckets inside the 7-day horizon, then a DATE-ONLY label —
* "6月20日" within the current year, "2025年6月20日" across years.
* `formatRelativeTimestamp`'s medium-date + time fallback
* ("2026年6月20日 16:33") is right for wide surfaces but crushed the
* session title next to it to ~2 characters. Minute precision belongs
* in tooltips/detail surfaces, not scan-level list rows.
*/
export function formatCompactTimestamp(
ts: number,
now: number = Date.now(),
locale: UiLocale = 'zh',
): string {
const diffMs = now - ts;
if (diffMs >= 0 && diffMs <= RELATIVE_HORIZON_MS) {
return formatRelativeTimestamp(ts, now, locale);
}
if (diffMs < 0) return formatRelativeTimestamp(ts, now, locale);
const { sameYear, otherYear } = getCompactFormats(locale);
const date = new Date(ts);
const nowDate = new Date(now);
return date.getFullYear() === nowDate.getFullYear()
? sameYear.format(date)
: otherYear.format(date);
}
/**
* Reset cached formatters for deterministic tests. Runtime calls select the
* cache with an explicit locale, so switching locale does not need a reset.
*/
export function resetRelativeTimeFormatters(): void {
cachedRelativeFormat = null;
cachedAbsoluteFormat = null;
cachedLocale = null;
cachedCompactSameYearFormat = null;
cachedCompactOtherYearFormat = null;
cachedCompactLocale = null;
}
/**
* Picks the next refresh delay (ms) for a relative timestamp. Used by
* the React `<RelativeTime>` ticker so we re-render at the right
* cadence: every second for sub-minute, every minute for sub-hour,
* every 10 minutes after that. Past the horizon we never re-render.
*/
export function nextRelativeRefreshDelay(ts: number, now: number = Date.now()): number | null {
const diffMs = now - ts;
if (diffMs > RELATIVE_HORIZON_MS) return null;
if (diffMs < 60_000) return 1_000;
if (diffMs < 60 * 60_000) return 60_000;
return 10 * 60_000;
}