Functional Weave
Code in Python

time.duration@1.0.0

impl/typescript.ts

2,991 bytes · the TypeScript implementation · view raw

import { type Duration } from "./time_duration_types.ts";

const MAX_DIGITS = 6;

function notADuration(text: string): RangeError {
  return new RangeError(`"${text}" is not a duration: write it as 1h30m, 90m or 01:30`);
}

function isDigit(ch: string): boolean {
  return ch >= "0" && ch <= "9";
}

/** Read a run of digits from `at`; returns [value, next index], or null if there is none. */
function readNumber(text: string, s: string, at: number): [number, number] | null {
  let end = at;
  while (end < s.length && isDigit(s[end])) end++;
  if (end === at) return null;
  if (end - at > MAX_DIGITS) throw notADuration(text);
  return [Number(s.slice(at, end)), end];
}

function skipSpaces(s: string, at: number): number {
  while (at < s.length && (s[at] === " " || s[at] === "\t")) at++;
  return at;
}

/** One written duration, in minutes. */
function parseOne(text: string): number {
  if (typeof text !== "string") throw notADuration(String(text));
  const s = text.slice(skipSpaces(text, 0)).replace(/[ \t]+$/, "");
  const colon = s.indexOf(":");
  if (colon >= 0) {
    const hours = readNumber(text, s, 0);
    if (hours === null || hours[1] !== colon) throw notADuration(text);
    const minutes = readNumber(text, s, colon + 1);
    if (minutes === null || minutes[1] !== s.length || minutes[1] - colon - 1 !== 2) throw notADuration(text);
    if (minutes[0] >= 60) throw new RangeError(`minutes must be under 60 when hours are given, in "${text}"`);
    return hours[0] * 60 + minutes[0];
  }
  let at = 0;
  let hours: number | null = null;
  let minutes: number | null = null;
  while (at < s.length) {
    const read = readNumber(text, s, at);
    if (read === null) throw notADuration(text);
    at = skipSpaces(s, read[1]);
    const unit = s[at];
    if ((unit === "h" || unit === "H") && hours === null && minutes === null) hours = read[0];
    else if ((unit === "m" || unit === "M") && minutes === null) minutes = read[0];
    else throw notADuration(text);
    at = skipSpaces(s, at + 1);
  }
  if (hours === null && minutes === null) throw notADuration(text);
  if (hours !== null && minutes !== null && minutes >= 60) {
    throw new RangeError(`minutes must be under 60 when hours are given, in "${text}"`);
  }
  return (hours ?? 0) * 60 + (minutes ?? 0);
}

/**
 * Parse each written duration, add them, and give the total in minutes and in
 * both written forms. The clock form does not wrap at 24 hours, because a
 * total of work is not a time of day.
 */
export function duration(parts: readonly string[]): Duration {
  let total = 0;
  for (const part of parts) total += parseOne(part);
  const hours = Math.floor(total / 60);
  const minutes = total - hours * 60;
  let text: string;
  if (hours > 0 && minutes > 0) text = `${hours}h${minutes}m`;
  else if (hours > 0) text = `${hours}h`;
  else text = `${minutes}m`;
  const clock = `${String(hours).padStart(2, "0")}:${String(minutes).padStart(2, "0")}`;
  return { minutes: total, text, clock };
}