mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 10:12:34 +00:00
Add distributed settings and workspace import/export (M5)
This commit is contained in:
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user