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`;
}