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                                542x 540x 542x 538x               498x 491x 484x 126x             542x 542x   538x   542x 401x 401x     401x   401x 401x 399x   397x           397x 397x 397x 397x   397x 395x 392x           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);
}