All files / src/lib format-date.ts

97.82% Statements 45/46
96.87% Branches 31/32
100% Functions 3/3
100% Lines 33/33

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83                                190x 188x 190x 186x               146x 139x 132x 126x             190x 190x   186x   190x 49x 49x     49x   49x 49x 47x   45x           45x 45x 45x 45x   45x 43x 40x           137x 137x 137x 137x     137x 112x 106x    
/**
 * Shared date formatting utilities for the Portal.
 *
 * formatRelativeDate — converts a date to a human-readable relative label.
 *
 * Two granularity modes:
 *   "day"  (default) — UTC-boundary day buckets (Today / Yesterday / Nd ago / …)
 *   "time"           — minute/hour precision, then falls through to day buckets
 *
 * Invalid inputs (null, undefined, NaN, unparseable strings) return "" without throwing.
 */
 
export type DateInput = string | number | Date;
export type RelativeDateOpts = { granularity?: "day" | "time" };
 
function toDate(input: DateInput | null | undefined): Date | null {
	if (input == null) return null;
	const d = input instanceof Date ? input : new Date(input);
	if (Number.isNaN(d.getTime())) return null;
	return d;
}
 
/**
 * Day-granular suffix computed from a pre-validated `diffDays` value.
 * Used by both granularity branches for the 2d+ range.
 */
function daysSuffix(diffDays: number): string {
	if (diffDays < 7) return `${diffDays}d ago`;
	if (diffDays < 30) return `${Math.floor(diffDays / 7)}w ago`;
	if (diffDays < 365) return `${Math.floor(diffDays / 30)}mo ago`;
	return `${Math.floor(diffDays / 365)}y ago`;
}
 
export function formatRelativeDate(
	input: DateInput | null | undefined,
	opts?: RelativeDateOpts,
): string {
	const date = toDate(input);
	if (date === null) return "";
 
	const granularity = opts?.granularity ?? "day";
 
	if (granularity === "time") {
		const now = new Date();
		const diffMs = now.getTime() - date.getTime();
 
		// Future dates: clamp to "Just now"
		Iif (diffMs < 0) return "Just now";
 
		const diffMins = Math.floor(diffMs / (1000 * 60));
		if (diffMins < 1) return "Just now";
		if (diffMins < 60) return `${diffMins}m ago`;
 
		const diffHours = Math.floor(diffMins / 60);
 
		// For 1h+, use UTC-boundary day diff so "Yesterday" is consistent with
		// granularity:"day" — both branches agree that Yesterday means UTC day diff === 1.
		// This covers the 23h-crossing-midnight case: 23h elapsed but different UTC day
		// correctly returns "Yesterday" rather than "23h ago".
		const DAY_MS = 1000 * 60 * 60 * 24;
		const utcDate = Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate());
		const utcNow = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate());
		const utcDiffDays = Math.floor((utcNow - utcDate) / DAY_MS);
		// Same UTC day → show hours (covers same-day multi-hour range)
		if (utcDiffDays <= 0) return `${diffHours}h ago`;
		if (utcDiffDays === 1) return "Yesterday";
		return daysSuffix(utcDiffDays);
	}
 
	// --- day granularity (UTC boundary) ---
	// Use UTC midnight values so the day boundary is consistent regardless of
	// the local timezone of the runtime (matches the original BlogCard pattern).
	const now = new Date();
	const utcDate = Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate());
	const utcNow = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate());
	const diffDays = Math.floor((utcNow - utcDate) / (1000 * 60 * 60 * 24));
 
	// Future dates: clamp to "Today"
	if (diffDays <= 0) return "Today";
	if (diffDays === 1) return "Yesterday";
	return daysSuffix(diffDays);
}