/* Copyright 2026 Marimo. All rights reserved. */ import { TZDate } from "@date-fns/tz"; import { formatDate, isMatch } from "date-fns"; import { Logger } from "./Logger"; export function prettyDate( value: string | number | null | undefined, type: "date" | "datetime", locale: string, ): string { if (value == null) { return ""; } try { // If type is date, drop the timezone by rendering in UTC // since dates are absolute if (type === "date") { return new Date(value).toLocaleDateString(locale, { year: "numeric", month: "short", day: "numeric", timeZone: "UTC", }); } // For datetime, we keep the original timezone return new Date(value).toLocaleDateString(locale, { year: "numeric", month: "short", day: "numeric", }); } catch (error) { Logger.warn("Failed to parse date", error); return value.toString(); } } /** * If the date has sub-second precision, it should say "2024-10-07 17:15:00.123". * Otherwise, it should say "2024-10-07 17:15:00". */ export function exactDateTime( value: Date, timezone: string | undefined, locale: string, ): string { const hasSubSeconds = value.getUTCMilliseconds() !== 0; try { if (timezone) { const valueWithTimezone = new TZDate(value, timezone); const shortTimeZone = getShortTimeZone(timezone, locale); if (hasSubSeconds) { return `${formatDate(valueWithTimezone, "yyyy-MM-dd HH:mm:ss.SSS")} ${shortTimeZone}`; } return `${formatDate(valueWithTimezone, "yyyy-MM-dd HH:mm:ss")} ${shortTimeZone}`; } if (hasSubSeconds) { return formatDate(value, "yyyy-MM-dd HH:mm:ss.SSS"); } return formatDate(value, "yyyy-MM-dd HH:mm:ss"); } catch (error) { Logger.warn("Failed to parse date", error); return value.toISOString(); } } export function getShortTimeZone(timezone: string, locale: string): string { try { const abbrev = new Intl.DateTimeFormat(locale, { timeZone: timezone, timeZoneName: "short", }) .formatToParts(new Date()) .find((part) => part.type === "timeZoneName")?.value; return abbrev ?? ""; } catch (error) { Logger.warn("Failed to get abbrev", error); return timezone; } } /** * If today, it should say "Today at 8:00 AM". * * If yesterday, it should say "Yesterday at 8:00 AM". * * If a date in the past, it should say " at 8:00 AM". */ export function timeAgo( value: string | number | null | undefined, locale: string, ): string { if (value == null) { return ""; } if (value === 0) { return ""; } try { const date = new Date(value); const today = new Date(); const yesterday = new Date(); yesterday.setDate(yesterday.getDate() - 1); if (date.toDateString() === today.toDateString()) { return `Today at ${date.toLocaleTimeString(locale, { hour: "numeric", minute: "numeric", })}`; } if (date.toDateString() === yesterday.toDateString()) { return `Yesterday at ${date.toLocaleTimeString(locale, { hour: "numeric", minute: "numeric", })}`; } return `${date.toLocaleDateString(locale, { year: "numeric", month: "short", day: "numeric", })} at ${date.toLocaleTimeString(locale, { hour: "numeric", minute: "numeric", })}`; } catch (error) { Logger.warn("Failed to parse date", error); } return value.toString(); } export const supportedDateFormats = ["yyyy", "yyyy-MM", "yyyy-MM-dd"] as const; export type DateFormat = (typeof supportedDateFormats)[number]; /** * If the string matches one of the supported date formats, return the format. */ export function getDateFormat(value: string): DateFormat | null { for (const format of supportedDateFormats) { if (isMatch(value, format)) { return format; } } return null; }