Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,15 @@ were in effect. `source_language` records what the model was *told* to expect
Post-processors (`hallucinations.apply`) consume one and return a new one via
`dataclasses.replace`, never mutating in place.

## Zoned clock

A transcript clock stamp carrying the UTC offset of the instant it shows
(`HH:MM:SS±HH:MM`), so the line reads apart from where it was rendered. The
shared rule behind the merged transcript's two renderings: text artefacts are
zoned, each naming its own zone, while an on-screen pane renders bare wall
time in the viewer's zone, which is the context there.
_Avoid_: "UTC stamp" (true only of the server's artefact). ADR-0026.

## Candidate languages · language pin

The operator's declaration of **which languages to expect** in a recording —
Expand Down
57 changes: 57 additions & 0 deletions docs/adr/0026-transcript-clocks-name-their-zone.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
---
status: accepted
date: 2026-09-30
---

# Transcript clocks name their zone; on-screen panes stay bare

One merged transcript renders two ways. The server writes `plain_text`
(`session_merge.render_transcript_text`) into `session-transcript.txt` and into
the summarizer's stdin. The browser builds the clipboard copy and the `.txt`
download (`transcript.buildCopyText`, which the download reuses byte for byte).
Both showed a bare `HH:MM:SS`, and that is ambiguous the moment the text leaves
the screen it was read on: a model reads the transcript and quotes those times
into notes nobody reads beside a clock (#438).

The rule, stated once in each renderer's docstring and cross-named: **a
rendered transcript line's clock names the zone it is in; a line on screen
needs none.** Text artefacts carry `HH:MM:SS±HH:MM`, each naming its own zone.
The merged pane, the live feed and the recordings spans keep bare `HH:MM:SS`
in the viewer's zone, which is the context there. See
[Zoned clock](../../CONTEXT.md#zoned-clock).

Nothing is threaded, because each renderer already holds the instant's zone.
`_clock` keeps whatever offset a datetime or a stored ISO carries, because
`parse_iso` never converts and merge instants are UTC.
`formatters.fmtClockZ` resolves ICU's per-instant `longOffset` under a pinned
`en-US` locale, so the artefact bytes are stable ASCII in every viewer
locale, and DST is the instant's, not today's. Both sides read a naive stamp
as UTC, the `parse_iso` convention, so one stored stamp names one instant.
An unreadable stamp stays `??:??:??` (JS `?`): an unknown time cannot name a
zone.

## Consequences

**Only the stored `session-transcript.txt` stays bare until re-merged.**
`batch_summarize._summary_input` and `buildCopyText` render again from the
stored segments, so the summarizer input, the copy and the `.txt` download of
an old session are zoned at once. They fall back to the stored `plain_text`
only for a body with no segments. Re-merging the session rewrites the file
zoned.

**`fmtSessionLabel` stays bare UTC.** It labels a session from the id's own
slice, not a transcript line, so the rule does not reach it. It is the
remaining bare UTC spelling in the UI.

## Considered options

**Local everywhere** (render each artefact in the viewer's zone). It needs a
viewer timezone threaded into the summarize request, and
`session-transcript.txt` is written by batch jobs that have no viewer. There
is no local for the server.

**UTC everywhere** (convert every clock). The pane would then disagree with
every other clock in the UI and with the wall clock the meeting happened on.

**A `times are UTC` header.** It evaporates the moment a line is excerpted or
quoted, which is exactly the flow that surfaced #438.
19 changes: 12 additions & 7 deletions tapscribe/session_merge.py
Original file line number Diff line number Diff line change
Expand Up @@ -307,28 +307,33 @@ def to_dict(self) -> dict[str, Any]:


def _clock(abs_start: Any) -> str:
"""`HH:MM:SS` from either a datetime (mid-merge) or its ISO string (read
back off `session-transcript.json`).
"""The zoned clock `HH:MM:SS±HH:MM` from either a datetime (mid-merge) or
its ISO string (read back off `session-transcript.json`). It keeps the
instant's own offset and reads a naive stamp as UTC, as `parse_iso` does.

An unreadable stamp renders `??:??:??` rather than raising: `parse_iso`
raises on malformed input, and this runs over a file on disk, so one bad
segment would fail a whole summarize instead of costing one line its clock.
"""
if isinstance(abs_start, datetime):
return abs_start.strftime("%H:%M:%S")
stamp = abs_start.isoformat() if isinstance(abs_start, datetime) else str(abs_start or "")
try:
dt = parse_iso(str(abs_start or ""))
dt = parse_iso(stamp)
except ValueError:
dt = None
if dt is None:
return "??:??:??"
return dt.strftime("%H:%M:%S") if dt else "??:??:??"
return dt.strftime("%H:%M:%S%:z")


def render_transcript_text(
segments: Iterable[Mapping[str, Any]],
*,
names: Mapping[str, str] | None = None,
) -> str:
"""The merged transcript as `[HH:MM:SS] Speaker: text` lines.
"""The merged transcript as `[HH:MM:SS±HH:MM] Speaker: text` lines.

The clock names its zone, because this text leaves the screen (stored
file, summarizer input). The JS twin is `formatters.fmtClockZ` (ADR-0026).

The ONE spelling of that format on this side of the wire: `merge_session`
builds `plain_text` through it, and `batch_summarize` re-renders the stored
Expand Down
6 changes: 3 additions & 3 deletions tapscribe/text.py
Original file line number Diff line number Diff line change
Expand Up @@ -116,9 +116,9 @@ def safe_name(s: str | None) -> str:


def parse_iso(s: str | None) -> datetime | None:
"""Parse an ISO-8601 timestamp into a tz-aware UTC datetime. Accepts
a trailing `Z`, treats naive timestamps as UTC, and returns None for
blank/missing input. Used by both the per-WAV sidecar reader and
"""Parse an ISO-8601 timestamp into a tz-aware datetime. Keeps an
explicit offset, reads a trailing `Z` and a naive timestamp as UTC, and
returns None for blank/missing input. Used by both the per-WAV sidecar reader and
the session merger."""
if not s:
return None
Expand Down
2 changes: 1 addition & 1 deletion tapscribe/web/components/next/views.html
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ <h1 class="whead__title" data-slot="title"></h1>
<div class="panel panel--primary panel--fill txpanel">
<!-- The EXPORT family, all built from the same alias-applied lines the
operator sees (raw identity keys → display names, one
"[hh:mm:ss] Name: text" line per non-suppressed segment):
"[hh:mm:ss±hh:mm] Name: text" line per non-suppressed segment):
copy → navigator.clipboard.writeText; txCopyStatus flashes
copied / failed / "nothing to copy" / "nothing to export".
txt → that same copy text as a FILE (Blob download, never the
Expand Down
69 changes: 51 additions & 18 deletions tapscribe/web/js/formatters.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
// @ts-check
// Pure formatters used across the dashboard — the APP layer next to the
// vendored toolkit formatters (lib/format.js): fmtClock renders viewer-zone
// wall clock via its own module-scope Intl formatter (see its docstring); the
// wall clock via its own module-scope Intl formatter (see its docstring), and
// fmtClockZ names that zone for text artefacts (see its docstring); the
// dense terminal-style number/duration forms (fmtDur, fmtMs, fmtMmSs,
// fmtBytes) are a deliberate Stages aesthetic and stay hand-rolled.
// No DOM dependency, no shared state — safe to unit-test in isolation.
Expand Down Expand Up @@ -45,27 +46,59 @@ export function fmtMs(ms) {
}

/** Wall-clock hh:mm:ss for an absolute instant (ISO-with-offset), rendered in
* the VIEWER's timezone (toolkit browser-timezone rule) — the old version
* sliced the ISO string, which showed every viewer the origin-encoded (UTC)
* wall time. The instant is unambiguous; the viewer's zone is the right
* display zone. Uses a module-scope Intl formatter instead of lib/format.js's
* `time()`: fmtClock runs once per merged-transcript segment (thousands per
* rebuild), and dfmt() only reference-caches its own two singletons — a
* custom options object pays a JSON.stringify cache-key per call.
* the VIEWER's timezone (toolkit browser-timezone rule). Uses a module-scope
* Intl formatter, not lib/format.js's `time()`: fmtClock runs once per
* merged-transcript segment, and dfmt() pays a JSON.stringify cache key per
* call for a custom options object.
* @param {string | null | undefined} iso */
export function fmtClock(iso) {
if (!iso) return "?";
// Guard unparseable timestamps: Intl's format() throws RangeError on an
// Invalid Date, and fmtClock runs inside whole-transcript row loops — a
// single corrupt sidecar value must garble one cell ("?"), never abort the
// entire render/copy (the old slice(11,19) form never threw either).
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return "?";
return CLOCK_FMT.format(d);
const d = toInstant(iso);
return d ? CLOCK_FMT.format(d) : "?";
}
const CLOCK_FMT = new Intl.DateTimeFormat(undefined, {
hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: false,
/** A 00-23 hour: `hourCycle`, not `hour12: false`, which some ICU builds
* resolve to h24 and so render midnight as 24:mm:ss. */
const CLOCK_PARTS = /** @type {const} */ ({
hour: "2-digit", minute: "2-digit", second: "2-digit", hourCycle: "h23",
});
const CLOCK_FMT = new Intl.DateTimeFormat(undefined, CLOCK_PARTS);

/** The Date for `iso`, or null when it is missing or unparseable. Intl's
* format() throws RangeError on an Invalid Date, and the clocks run inside
* whole-transcript row loops: one corrupt sidecar value must garble one cell
* ("?"), never abort the entire render or copy.
* @param {string | null | undefined} iso */
function toInstant(iso) {
if (!iso) return null;
const d = new Date(iso);
return Number.isNaN(d.getTime()) ? null : d;
}

/** Zoned clock `HH:MM:SS±HH:MM` for a transcript line that leaves the screen
* (clipboard copy, .txt download): the reading `fmtClock` shows, plus the
* instant's own offset. The server twin is `session_merge._clock` (ADR-0026).
* `timeZone` defaults to the viewer's zone.
* @param {string | null | undefined} iso
* @param {string} [timeZone] */
export function fmtClockZ(iso, timeZone) {
const d = toInstant(iso && NAIVE_ISO.test(iso) ? `${iso}Z` : iso);
if (!d) return "?";
const parts = Object.fromEntries(clockZFormatter(timeZone).formatToParts(d).map((p) => [p.type, p.value]));
// ICU spells a zero offset as a bare "GMT".
const offset = String(parts.timeZoneName).replace(/^GMT/, "") || "+00:00";
return `${parts.hour}:${parts.minute}:${parts.second}${offset}`;
}
/** A date-time with no offset, which `new Date` reads as viewer-local time.
* The server's `parse_iso` reads it as UTC, so fmtClockZ does too. */
const NAIVE_ISO = /T\d{2}:\d{2}(:\d{2}(\.\d+)?)?$/;
// "en-US", not undefined: the viewer locale localises the offset (da-DK writes
// `GMT+01.00`, fr-FR `UTC+01:00`), and an artefact needs stable ASCII.
const CLOCK_Z_PARTS = /** @type {const} */ ({ ...CLOCK_PARTS, timeZoneName: "longOffset" });
const CLOCK_Z_FMT = new Intl.DateTimeFormat("en-US", CLOCK_Z_PARTS);
/** The viewer-zone formatter, or a fresh one pinned to `timeZone`.
* @param {string} [timeZone] */
function clockZFormatter(timeZone) {
return timeZone ? new Intl.DateTimeFormat("en-US", { ...CLOCK_Z_PARTS, timeZone }) : CLOCK_Z_FMT;
}

/**
* Elapsed seconds → compact "m:ss" (90 → "1:30"). Distinct from `fmtDur`
Expand Down
50 changes: 50 additions & 0 deletions tapscribe/web/js/formatters.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,14 @@

import { test } from "node:test";
import assert from "node:assert/strict";
import { execFileSync } from "node:child_process";

import {
fmtBytes,
fmtDur,
fmtMs,
fmtClock,
fmtClockZ,
fmtMmSs,
truncMid,
fmtSessionLabel,
Expand Down Expand Up @@ -122,6 +124,54 @@ test("fmtClock renders a parseable instant as hh:mm:ss", () => {
assert.match(fmtClock("2026-05-12T09:19:55Z"), /^\d{2}:\d{2}:55$/);
});

// ── fmtClockZ (the zoned artefact clock) ──
// The zone argument pins LITERAL strings on any runner. The offset is the
// instant's, so DST is the instant's, not today's.

test("fmtClockZ stamps the instant's own UTC offset", () => {
assert.equal(fmtClockZ("2026-05-12T12:05:12Z", "UTC"), "12:05:12+00:00");
// Half-hour zones keep their minutes.
assert.equal(fmtClockZ("2026-05-12T12:05:12Z", "Asia/Kolkata"), "17:35:12+05:30");
});

test("fmtClockZ resolves the DST offset of the instant, not of today", () => {
assert.equal(fmtClockZ("2026-01-12T12:05:12Z", "America/New_York"), "07:05:12-05:00");
assert.equal(fmtClockZ("2026-07-12T12:05:12Z", "America/New_York"), "08:05:12-04:00");
});

test("fmtClockZ renders midnight as hour 00, never 24", () => {
assert.equal(fmtClockZ("2026-01-12T00:05:06Z", "UTC"), "00:05:06+00:00");
});

test("fmtClockZ reads a naive stamp as UTC, as the server's parse_iso does", () => {
assert.equal(fmtClockZ("2026-05-12T12:05:12", "Europe/Oslo"), "14:05:12+02:00");
assert.equal(fmtClockZ("2026-05-12T12:05:12.250", "UTC"), "12:05:12+00:00");
});

test("fmtClockZ keeps the ASCII spelling under a non-English viewer locale", () => {
// The runner is en-US, so only a child process with another locale sees a
// formatter that follows the viewer locale (da-DK writes `+01.00`).
const script = `
import { fmtClockZ } from ${JSON.stringify(new URL("./formatters.js", import.meta.url).href)};
const locale = new Intl.DateTimeFormat().resolvedOptions().locale;
process.stdout.write(JSON.stringify([locale, fmtClockZ("2026-01-12T12:05:12Z", "Europe/Copenhagen")]));`;
const out = execFileSync(process.execPath, ["--input-type=module", "-e", script], {
env: { ...process.env, LANG: "da_DK.UTF-8", LC_ALL: "da_DK.UTF-8" },
encoding: "utf8",
});
const [locale, clock] = JSON.parse(out);
assert.equal(locale, "da-DK", "the child must run under da-DK, or this test proves nothing");
assert.equal(clock, "13:05:12+01:00");
});

test("fmtClockZ returns ? for missing or unparseable input", () => {
// Never "?+00:00": an unknown time cannot name a zone.
assert.equal(fmtClockZ(null), "?");
assert.equal(fmtClockZ(undefined), "?");
assert.equal(fmtClockZ(""), "?");
assert.equal(fmtClockZ("not-a-timestamp"), "?");
});

// ---- fmtMmSs -------------------------------------------------------------

test("fmtMmSs renders floored m:ss with a zero-padded seconds field", () => {
Expand Down
59 changes: 28 additions & 31 deletions tapscribe/web/js/next/views/transcript.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ import {
import { createFilesSource, listState } from "../session-files.js";
import { resolveSeekTarget } from "../seek-target.js";
import { wireSave, runSaveWithStatus, statusTarget } from "../../save-status.js";
import { fmtBytes, fmtClock, fmtDur, fmtMs, truncMid } from "../../formatters.js";
import { fmtBytes, fmtClockZ, fmtDur, fmtMs, truncMid } from "../../formatters.js";
import { aliasOf } from "../../speakers.js";
import { header, strong, inline, buildSourceToggle, renderJobBar, effectiveSource, setSourcePick, sessionLabel } from "../shell.js";
import { makeStatusFlasher, copyToClipboard, downloadFile, setText, showTextForManualCopy } from "../ui.js";
Expand Down Expand Up @@ -129,9 +129,9 @@ function* aliasedSegments(full, meta) {
* · A segment whose `abs_start` will not parse is DROPPED rather than emitted
* with a NaN stamp. NaN would latch into `tZero` (NaN !== null), poisoning
* every later `absStart - tZero`, so ONE corrupt value in a hand-edited or
* truncated sidecar would garble the whole document — the same invariant
* fmtClock states for the copy path ("a single corrupt sidecar value must
* garble one cell, never abort the entire render"). A parseable start with a
* truncated sidecar would garble the whole document. This is the invariant
* formatters.toInstant states for the clocks ("one corrupt sidecar value
* must garble one cell"). A parseable start with a
* corrupt end keeps its line, as a zero-length cue.
*
* Pure — module-scope (not a build() closure local) so the unit tests can reach
Expand Down Expand Up @@ -159,6 +159,26 @@ export function buildExportSegments(full, meta) {
return segments;
}

/**
* The text behind the clipboard copy and the .txt download: one
* "[HH:MM:SS±HH:MM] Alias: text" line per aliasedSegments line, with the
* zoned `fmtClockZ` clock (ADR-0026) and an "[uncertain]" suffix on a
* low-confidence line. It falls back to plain_text, which has raw speaker
* keys, when no segment yields a line.
* @param {import('../../types.js').MergedTranscript} full
* @param {import('../../types.js').EffectiveMeta} meta
* @returns {string}
*/
export function buildCopyText(full, meta) {
const lines = [];
for (const { seg, text, speaker } of aliasedSegments(full, meta)) {
let line = `[${fmtClockZ(seg.abs_start)}] ${speaker}: ${text}`;
if (seg.low_confidence) line += " [uncertain]";
lines.push(line);
}
return lines.join("\n") || full.plain_text || "";
}

/**
* @param {{
* metaFor: (s: import('../../types.js').Session) => import('../../types.js').EffectiveMeta,
Expand Down Expand Up @@ -597,28 +617,6 @@ export function build(ctx) {

// ---- Copy merged transcript (ported from classic main.js onCopyMerged) ----

// Rebuild the export text from segments so display-name aliases match what
// the user sees — the backend's `plain_text` uses raw speaker keys. Which
// segments qualify and what a speaker is called is the shared aliasedSegments
// walk (the same line set the .srt/.vtt exports get); this function owns only
// the LINE format: "[hh:mm:ss] Alias: text", "[uncertain]" suffix on
// low-confidence lines, an ABSOLUTE wall clock. Falls back to plain_text when
// no segments produced a line.
/**
* @param {import('../../types.js').MergedTranscript} full
* @param {import('../../types.js').EffectiveMeta} meta
* @returns {string}
*/
const buildCopyText = (full, meta) => {
const lines = [];
for (const { seg, text, speaker } of aliasedSegments(full, meta)) {
let line = `[${fmtClock(seg.abs_start)}] ${speaker}: ${text}`;
if (seg.low_confidence) line += " [uncertain]";
lines.push(line);
}
return lines.join("\n") || full.plain_text || "";
};

const flashCopyStatus = makeStatusFlasher(txCopyStatus);

// Bound ONCE at build time; reads the captured copyTxFull/copyMeta (the body
Expand Down Expand Up @@ -659,11 +657,10 @@ export function build(ctx) {
});
};

// .txt reuses buildCopyText, so the bytes are identical to the clipboard copy
// (not the backend's plain_text, which carries raw speaker keys). .srt/.vtt
// go through the relative-clock conversion; an empty cue list renders as ""
// rather than toVTT([])'s truthy header-only document, which is not a file
// worth handing the operator.
// .txt reuses buildCopyText, so its bytes are identical to the clipboard
// copy. .srt/.vtt go through the relative-clock conversion; an empty cue
// list renders as "" rather than toVTT([])'s truthy header-only document,
// which is not a file worth handing the operator.
wireExport(txDownloadTxt, ".txt", "text/plain;charset=utf-8", buildCopyText);
wireExport(txDownloadSrt, ".srt", "application/x-subrip", (full, meta) => {
const segments = buildExportSegments(full, meta);
Expand Down
Loading
Loading