Files
oh-my-pi/packages/utils/src/dates.ts
can1357 e9888367d1 refactor: migrated packages to internal utility modules and removed external dependencies
- Implemented in-house, zero-dependency utility modules in `pi-utils` covering DOM manipulation, markdown parsing, templating, browser automation helpers, and terminal buffers.
- Migrated packages across the repository to consume the new internal utilities and `omptype` schema validators instead of external dependencies.
- Removed multiple external runtime and development dependencies including Zod, Marked, LRU cache, Turndown, and Puppeteer browser packages.
2026-08-05 13:39:09 +02:00

195 lines
5.2 KiB
TypeScript

/** Behavior-compatible reimplementation of date-fns's used surface. */
const MONTHS = [
"January",
"February",
"March",
"April",
"May",
"June",
"July",
"August",
"September",
"October",
"November",
"December",
] as const;
const WEEKDAYS = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"] as const;
const TOKENS = [
"MMMM",
"yyyy",
"EEEE",
"MMM",
"EEE",
"yy",
"MM",
"dd",
"HH",
"hh",
"mm",
"ss",
"M",
"d",
"H",
"h",
"m",
"s",
"a",
] as const;
function pad(value: number, length = 2): string {
return String(value).padStart(length, "0");
}
function tokenValue(date: Date, token: (typeof TOKENS)[number]): string {
const month = date.getMonth();
const weekday = date.getDay();
const hours = date.getHours();
const hours12 = hours % 12 || 12;
switch (token) {
case "yyyy":
return pad(date.getFullYear(), 4);
case "yy":
return pad(date.getFullYear() % 100);
case "MMMM":
return MONTHS[month];
case "MMM":
return MONTHS[month].slice(0, 3);
case "MM":
return pad(month + 1);
case "M":
return String(month + 1);
case "dd":
return pad(date.getDate());
case "d":
return String(date.getDate());
case "EEEE":
return WEEKDAYS[weekday];
case "EEE":
return WEEKDAYS[weekday].slice(0, 3);
case "HH":
return pad(hours);
case "H":
return String(hours);
case "hh":
return pad(hours12);
case "h":
return String(hours12);
case "mm":
return pad(date.getMinutes());
case "m":
return String(date.getMinutes());
case "ss":
return pad(date.getSeconds());
case "s":
return String(date.getSeconds());
case "a":
return hours < 12 ? "AM" : "PM";
}
}
function asDate(value: Date | number): Date {
const date = value instanceof Date ? new Date(value.getTime()) : new Date(value);
if (Number.isNaN(date.getTime())) throw new RangeError("Invalid time value");
return date;
}
/** Format a date with the supported date-fns v4 tokens and quoted literals. */
export function format(value: Date | number, pattern: string): string {
const date = asDate(value);
let result = "";
for (let index = 0; index < pattern.length; ) {
if (pattern[index] === "'") {
if (pattern[index + 1] === "'") {
result += "'";
index += 2;
continue;
}
index++;
while (index < pattern.length) {
if (pattern[index] !== "'") {
result += pattern[index++];
continue;
}
if (pattern[index + 1] === "'") {
result += "'";
index += 2;
continue;
}
index++;
break;
}
continue;
}
const rest = pattern.slice(index);
const token = TOKENS.find(candidate => rest.startsWith(candidate));
if (token) {
result += tokenValue(date, token);
index += token.length;
continue;
}
const character = pattern[index++];
if (/[A-Za-z]/.test(character)) {
throw new RangeError(`Format string contains an unescaped latin alphabet character \`${character}\``);
}
result += character;
}
return result;
}
function plural(count: number, singular: string): string {
return `${count} ${count === 1 ? singular : `${singular}s`}`;
}
function completedMonths(earlier: Date, later: Date): number {
let months = (later.getFullYear() - earlier.getFullYear()) * 12 + later.getMonth() - earlier.getMonth();
if (months === 0) return 0;
const candidate = new Date(earlier);
candidate.setDate(1);
candidate.setFullYear(earlier.getFullYear(), earlier.getMonth() + months, 1);
const lastDay = new Date(candidate.getFullYear(), candidate.getMonth() + 1, 0).getDate();
candidate.setDate(Math.min(earlier.getDate(), lastDay));
if (candidate.getTime() > later.getTime()) months--;
return months;
}
function distanceWords(earlier: Date, later: Date): string {
const seconds = (later.getTime() - earlier.getTime()) / 1_000;
const timezoneOffset = (later.getTimezoneOffset() - earlier.getTimezoneOffset()) * 60;
const minutes = Math.round((seconds - timezoneOffset) / 60);
if (minutes < 2) return minutes === 0 ? "less than a minute" : "1 minute";
if (minutes < 45) return plural(minutes, "minute");
if (minutes < 90) return "about 1 hour";
if (minutes < 1_440) return `about ${plural(Math.round(minutes / 60), "hour")}`;
if (minutes < 2_520) return "1 day";
if (minutes < 43_200) return plural(Math.round(minutes / 1_440), "day");
if (minutes < 86_400) return `about ${plural(Math.round(minutes / 43_200), "month")}`;
const months = completedMonths(earlier, later);
if (months < 12) return plural(Math.round(minutes / 43_200), "month");
const years = Math.trunc(months / 12);
const remainingMonths = months % 12;
if (remainingMonths < 3) return `about ${plural(years, "year")}`;
if (remainingMonths < 9) return `over ${plural(years, "year")}`;
return `almost ${plural(years + 1, "year")}`;
}
/** Describe the distance from a date to now using date-fns's English thresholds. */
export function formatDistanceToNow(value: Date | number, options: { addSuffix?: boolean } = {}): string {
const date = asDate(value);
const now = new Date(Date.now());
const isFuture = date.getTime() > now.getTime();
const words = isFuture ? distanceWords(now, date) : distanceWords(date, now);
if (!options.addSuffix) return words;
return isFuture ? `in ${words}` : `${words} ago`;
}