diff --git a/CONTEXT.md b/CONTEXT.md index 0839531b..bdf38342 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -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 — diff --git a/docs/adr/0026-transcript-clocks-name-their-zone.md b/docs/adr/0026-transcript-clocks-name-their-zone.md new file mode 100644 index 00000000..5906950f --- /dev/null +++ b/docs/adr/0026-transcript-clocks-name-their-zone.md @@ -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. diff --git a/tapscribe/session_merge.py b/tapscribe/session_merge.py index e6fc9e51..54680ef6 100644 --- a/tapscribe/session_merge.py +++ b/tapscribe/session_merge.py @@ -307,20 +307,22 @@ 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( @@ -328,7 +330,10 @@ def render_transcript_text( *, 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 diff --git a/tapscribe/text.py b/tapscribe/text.py index e522078f..4e1878c9 100644 --- a/tapscribe/text.py +++ b/tapscribe/text.py @@ -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 diff --git a/tapscribe/web/components/next/views.html b/tapscribe/web/components/next/views.html index f5c9d565..421ffd1d 100644 --- a/tapscribe/web/components/next/views.html +++ b/tapscribe/web/components/next/views.html @@ -118,7 +118,7 @@