mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 02:02:33 +00:00
138 lines
4.4 KiB
TypeScript
138 lines
4.4 KiB
TypeScript
/**
|
|
* Date formatting — how timestamps render throughout the app (spec §07 →
|
|
* Formatting). Governs the Snippet Library list dates and the metadata panel's
|
|
* Created/Modified, driven by the user's `formatting.dateFormat` setting.
|
|
*
|
|
* Portable core: no browser APIs, no React. `Intl` is deliberately avoided for
|
|
* the custom tokens so output is locale-stable and unit-testable; month/day
|
|
* names are the fixed English set. Pure: an ISO string in, a display string out.
|
|
*
|
|
* - smart → relative, human-friendly ("Today", "Yesterday", "3d ago", then a
|
|
* full locale date for older items).
|
|
* - iso → a full ISO 8601 timestamp.
|
|
* - custom → the user's format string (token grammar below); falls back to ISO
|
|
* when the pattern is empty.
|
|
*/
|
|
|
|
/** The three date-display modes (mirrors UserSettings.formatting.dateFormat). */
|
|
export type DateFormatMode = 'smart' | 'iso' | 'custom';
|
|
|
|
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
|
|
const MONTHS_SHORT = [
|
|
'Jan',
|
|
'Feb',
|
|
'Mar',
|
|
'Apr',
|
|
'May',
|
|
'Jun',
|
|
'Jul',
|
|
'Aug',
|
|
'Sep',
|
|
'Oct',
|
|
'Nov',
|
|
'Dec',
|
|
];
|
|
const MONTHS_LONG = [
|
|
'January',
|
|
'February',
|
|
'March',
|
|
'April',
|
|
'May',
|
|
'June',
|
|
'July',
|
|
'August',
|
|
'September',
|
|
'October',
|
|
'November',
|
|
'December',
|
|
];
|
|
|
|
/** Two-digit zero-pad (mirrors snippet.ts → generateSnippetName). */
|
|
function pad(n: number): string {
|
|
return String(n).padStart(2, '0');
|
|
}
|
|
|
|
/**
|
|
* Relative, human-friendly rendering (spec §07 → Smart). Same day → "Today",
|
|
* one calendar day back → "Yesterday", under a week → "Nd ago", else the full
|
|
* locale date. Comparison is by calendar day (local), so "Yesterday" doesn't
|
|
* flip on the exact hour. `now` is injectable for deterministic tests.
|
|
*/
|
|
export function formatSmart(date: Date, now: Date): string {
|
|
const startOfDay = (d: Date) => new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
|
|
const days = Math.floor((startOfDay(now) - startOfDay(date)) / DAY_MS);
|
|
if (days <= 0) return 'Today';
|
|
if (days === 1) return 'Yesterday';
|
|
if (days < 7) return `${days}d ago`;
|
|
return date.toLocaleDateString();
|
|
}
|
|
|
|
/** Full ISO 8601 timestamp (spec §07 → ISO 8601). */
|
|
export function formatIso(date: Date): string {
|
|
return date.toISOString();
|
|
}
|
|
|
|
// Tokens longest-first so MMMM matches before MMM before MM before M, etc.
|
|
const TOKEN = /yyyy|yy|MMMM|MMM|MM|M|dd|d|HH|H|hh|h|mm|m|ss|s|a/g;
|
|
|
|
/**
|
|
* Format a date with a date-fns-style token pattern, in **local** time
|
|
* (spec §07 → Custom). Supported tokens: `yyyy yy MMMM MMM MM M dd d HH H hh h
|
|
* mm m ss s a`. Text between tokens is preserved verbatim (no quoting), which is
|
|
* enough for patterns like `yyyy-MM-dd HH:mm`; a stray literal letter that
|
|
* happens to be a token (e.g. a `d` in prose) would be substituted — the field
|
|
* is a power-user affordance, so this stays simple and predictable.
|
|
*/
|
|
export function formatCustom(date: Date, pattern: string): string {
|
|
const h24 = date.getHours();
|
|
const h12 = h24 % 12 === 0 ? 12 : h24 % 12;
|
|
const map: Record<string, string> = {
|
|
yyyy: String(date.getFullYear()),
|
|
yy: pad(date.getFullYear() % 100),
|
|
MMMM: MONTHS_LONG[date.getMonth()],
|
|
MMM: MONTHS_SHORT[date.getMonth()],
|
|
MM: pad(date.getMonth() + 1),
|
|
M: String(date.getMonth() + 1),
|
|
dd: pad(date.getDate()),
|
|
d: String(date.getDate()),
|
|
HH: pad(h24),
|
|
H: String(h24),
|
|
hh: pad(h12),
|
|
h: String(h12),
|
|
mm: pad(date.getMinutes()),
|
|
m: String(date.getMinutes()),
|
|
ss: pad(date.getSeconds()),
|
|
s: String(date.getSeconds()),
|
|
a: h24 < 12 ? 'AM' : 'PM',
|
|
};
|
|
return pattern.replace(TOKEN, (t) => map[t] ?? t);
|
|
}
|
|
|
|
/**
|
|
* Render an ISO timestamp per the user's date-format setting (spec §07). An
|
|
* unparseable timestamp is returned verbatim rather than rendered as "Invalid
|
|
* Date", so a malformed stored value degrades gracefully. `customFormat` is used
|
|
* only in `custom` mode and falls back to ISO when blank. `now` (for `smart`)
|
|
* defaults to the current time; inject it in tests.
|
|
*/
|
|
export function formatDate(
|
|
iso: string,
|
|
mode: DateFormatMode,
|
|
customFormat = '',
|
|
now: Date = new Date(),
|
|
): string {
|
|
const date = new Date(iso);
|
|
if (Number.isNaN(date.getTime())) return iso;
|
|
|
|
switch (mode) {
|
|
case 'iso':
|
|
return formatIso(date);
|
|
case 'custom':
|
|
return customFormat.trim() === '' ? formatIso(date) : formatCustom(date, customFormat);
|
|
case 'smart':
|
|
default:
|
|
return formatSmart(date, now);
|
|
}
|
|
}
|