Shared UI kit

format.js 96 lines

Files · 11
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596export const number = (value) =>
  new Intl.NumberFormat(undefined, { maximumFractionDigits: 2 }).format(value);
export function displayDate(value, withTime = false) {
  if (!value) return "No date";
  // Local Strava time must stay a wall clock, even with a trailing Z.
  const parts = value.match(
    /^(\d{4})[/-](\d{2})[/-](\d{2})(?:T(\d{2}):(\d{2}))?/,
  );
  if (parts) {
    const date = new Date(
      Number(parts[1]),
      Number(parts[2]) - 1,
      Number(parts[3]),
    );
    return (
      date.toLocaleDateString(undefined, {
        month: "short",
        day: "numeric",
        year: "numeric",
      }) + (withTime && parts[4] ? ` · ${parts[4]}:${parts[5]}` : "")
    );
  }
  const date = new Date(value);
  return Number.isNaN(date.getTime())
    ? value
    : date.toLocaleDateString(undefined, {
        month: "short",
        day: "numeric",
        year: "numeric",
      });
}
export const readingDate = () => localISO().slice(0, 10).replaceAll("-", "/");
export function localISO() {
  const date = new Date();
  return new Date(date.getTime() - date.getTimezoneOffset() * 60000)
    .toISOString()
    .slice(0, 19);
}
export function money(cost, currency) {
  // Keep decimal strings; never round via a floating point conversion.
  const [whole, fraction = ""] = cost.split(".");
  return `${currency} ${whole.replace(/\B(?=(\d{3})+(?!\d))/g, ",")}.${fraction.padEnd(2, "0")}`;
}
export function totalsByCurrency(rows, payment) {
  const totals = new Map();
  for (const row of rows.filter((row) => row.payment === payment)) {
    const [whole, fraction = ""] = row.cost.split(".");
    const cents = BigInt(whole) * 100n + BigInt(fraction.padEnd(2, "0"));
    totals.set(
      row.currency_code,
      (totals.get(row.currency_code) || 0n) + cents,
    );
  }
  return (
    [...totals]
      .map(([currency, cents]) =>
        money(
          `${cents / 100n}.${String(cents % 100n).padStart(2, "0")}`,
          currency,
        ),
      )
      .join(" · ") || "—"
  );
}

export function addDays(date, amount) {
  const d = new Date(`${date}T12:00:00Z`);
  d.setUTCDate(d.getUTCDate() + amount);
  return d.toISOString().slice(0, 10);
}
export const nullable = (value) =>
  value === "" || value === undefined ? null : value;
export function durationLabel(value) {
  if (!value) return "—";
  const match =
    /^P(?:(\d+(?:\.\d+)?)Y)?(?:(\d+(?:\.\d+)?)M)?(?:(\d+(?:\.\d+)?)W)?(?:(\d+(?:\.\d+)?)D)?(?:T(?:(\d+(?:\.\d+)?)H)?(?:(\d+(?:\.\d+)?)M)?(?:(\d+(?:\.\d+)?)S)?)?$/.exec(
      value,
    );
  if (!match) return value;
  return (
    match
      .slice(1)
      .map((v, i) =>
        v === undefined
          ? ""
          : `${v} ${["yr", "mo", "wk", "day", "hr", "min", "sec"][i]}`,
      )
      .filter(Boolean)
      .join(" ") || value
  );
}
export const isDuration = (value) =>
  !value ||
  /^P(?=\d|T\d)(?:\d+(?:\.\d+)?Y)?(?:\d+(?:\.\d+)?M)?(?:\d+(?:\.\d+)?W)?(?:\d+(?:\.\d+)?D)?(?:T(?=\d)(?:\d+(?:\.\d+)?H)?(?:\d+(?:\.\d+)?M)?(?:\d+(?:\.\d+)?S)?)?$/.test(
    value,
  );