timestamps.ts4.8 KBView on GitHub
/**
 * Clock ↔ seconds conversion, and the per-provider deep-link URLs built from it.
 *
 * A coaching document writes times the way a person reads them (`12:40`), and every player
 * — ours and every provider's — wants seconds. This is the one place that converts, so a
 * seek and the link beside it can never disagree about which second a moment is at.
 */

/**
 * `12:40` / `01:12:40` / `760` → seconds. Null when the value is not a time.
 *
 * Accepts the three shapes that actually reach documents: `MM:SS` (what the coaching agent
 * writes), `HH:MM:SS` (what the Fathom and Circleback transcript flatteners write), and a
 * bare seconds count. Anything else returns null rather than a guess — a player that seeks
 * to a wrong second is worse than one that starts at the beginning, because the reader has
 * no way to tell it went to the wrong place.
 */
export function clockToSeconds(raw: string | null | undefined): number | null {
  if (raw === null || raw === undefined) return null;
  const t = String(raw).trim();
  if (!t) return null;

  const parts = t.split(':');
  if (parts.length > 3) return null;
  if (!parts.every((p) => /^\d+$/.test(p))) return null;

  // Right-to-left: the last part is always seconds, so `MM:SS` and `HH:MM:SS` need no branch.
  const seconds = parts.reduce((acc, part) => acc * 60 + Number(part), 0);
  return Number.isFinite(seconds) && seconds >= 0 ? seconds : null;
}

/** Seconds → `12:40`, or `1:12:40` past an hour. The inverse of what documents contain. */
export function secondsToClock(total: number): string {
  const s = Math.max(0, Math.floor(total));
  const pad = (n: number) => String(n).padStart(2, '0');
  const hours = Math.floor(s / 3600);
  const mins = Math.floor((s % 3600) / 60);
  return hours > 0 ? `${hours}:${pad(mins)}:${pad(s % 60)}` : `${mins}:${pad(s % 60)}`;
}

/**
 * Add a moment to a provider's own page URL, so "open at the provider" lands where the
 * quote is rather than at 0:00.
 *
 * Each provider spells this differently and only some spell it at all — verified live
 * 2026-08-27, see apps/server/docs/inline-meeting-video-in-documents.md §2:
 *
 *   fathom  — `?timestamp=<seconds>` on the share page. Confirmed: the page's own props
 *             come back carrying `currentTime: 125.0` for `?timestamp=125`.
 *   gong    — `&from=<seconds>&to=<seconds>` on the call page. Gong requires a span, not a
 *             point, so a short window is opened around the moment.
 *   others  — no known parameter; the URL is returned untouched rather than decorated with
 *             one that does nothing.
 *
 * Returns the input unchanged when there is no timestamp, and null only when there was no
 * URL to begin with.
 */
export function providerUrlAtSecond(
  url: string | null | undefined,
  provider: string | null | undefined,
  atSeconds: number | null,
): string | null {
  if (!url) return null;
  if (atSeconds === null || !Number.isFinite(atSeconds) || atSeconds < 0) return url;

  let parsed: URL;
  try {
    parsed = new URL(url);
  } catch {
    return url;
  }

  const at = Math.floor(atSeconds);
  switch (provider) {
    case 'fathom':
      parsed.searchParams.set('timestamp', String(at));
      return parsed.toString();
    case 'gong':
      parsed.searchParams.set('from', String(at));
      parsed.searchParams.set('to', String(at + GONG_SNIPPET_SPAN_SECONDS));
      return parsed.toString();
    default:
      return url;
  }
}

/** Gong's deep link is a span; this is how much of the call after the moment it covers. */
const GONG_SNIPPET_SPAN_SECONDS = 30;

/**
 * Fathom's embeddable player URL for a share link, or null when the URL is not one.
 *
 * `fathom.video/share/<token>` cannot be framed (`X-Frame-Options: SAMEORIGIN`), but
 * `fathom.video/embed/<token>` can: verified 2026-08-27, it answers 200 with no
 * `X-Frame-Options` and no `frame-ancestors` in its CSP. Same token, different route.
 *
 * IT CANNOT BE SEEKED. The embed accepts `autoplay` and nothing else — its component
 * declares only `{call, autoplay, shareUrl, displayMode}` and its bundle contains no
 * timestamp handling, and nine candidate parameter names were probed against the live
 * endpoint with no effect. So this is the LAST rung of the fallback ladder, used when the
 * media itself could not be minted: a player positioned at 0:00 still beats no player, but
 * it is strictly worse than the `<video>` path and must never be preferred over it.
 */
export function fathomEmbedUrl(shareUrl: string | null | undefined): string | null {
  if (!shareUrl) return null;
  let parsed: URL;
  try {
    parsed = new URL(shareUrl);
  } catch {
    return null;
  }
  if (parsed.hostname !== 'fathom.video') return null;

  const match = /^\/share\/([A-Za-z0-9_-]+)\/?$/.exec(parsed.pathname);
  if (!match) return null;
  return `https://fathom.video/embed/${match[1]}?autoplay=0`;
}