@crvouga/atlas 1.10.0 → 1.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/CHANGELOG.md +15 -1
  2. package/dist/reports/src/review-collection-store.d.ts +47 -0
  3. package/dist/reports/src/review-collection-store.js +214 -0
  4. package/dist/reports/src/review-collection.d.ts +88 -0
  5. package/dist/reports/src/review-collection.js +144 -0
  6. package/dist/reports/src/test-catalog.d.ts +2 -0
  7. package/dist/reports/src/test-catalog.js +1 -1
  8. package/dist/schema/src/api.d.ts +574 -0
  9. package/dist/schema/src/api.js +152 -1
  10. package/dist/server/src/app.js +4 -1
  11. package/dist/server/src/openapi.js +46 -1
  12. package/dist/server/src/review-collections.d.ts +39 -0
  13. package/dist/server/src/review-collections.js +179 -0
  14. package/docs/extending.md +8 -1
  15. package/docs/reference/http-api.md +122 -0
  16. package/docs/reference/reports.md +294 -0
  17. package/docs/reference/schema.md +131 -0
  18. package/docs/reference/server.md +44 -0
  19. package/docs/rich-evidence.md +77 -0
  20. package/docs/server.md +41 -0
  21. package/docs/ui.md +67 -0
  22. package/llms-full.txt +315 -1
  23. package/package.json +1 -1
  24. package/packages/schema/schemas/review-collection.schema.json +398 -0
  25. package/packages/schema/schemas/review-manifest.schema.json +475 -0
  26. package/packages/server/openapi.json +6729 -4084
  27. package/packages/visualizer/src/app/router.tsx +9 -1
  28. package/packages/visualizer/src/evidence/applicability-view.tsx +233 -0
  29. package/packages/visualizer/src/evidence/applicability.test.ts +131 -0
  30. package/packages/visualizer/src/evidence/applicability.ts +241 -0
  31. package/packages/visualizer/src/evidence/artifact-tags.tsx +2 -1
  32. package/packages/visualizer/src/evidence/artifacts.tsx +88 -42
  33. package/packages/visualizer/src/evidence/behavior-panel.tsx +55 -26
  34. package/packages/visualizer/src/evidence/feature-page.tsx +25 -4
  35. package/packages/visualizer/src/evidence/media-timing.test.ts +85 -0
  36. package/packages/visualizer/src/evidence/media-timing.ts +150 -0
  37. package/packages/visualizer/src/evidence/moments.tsx +457 -0
  38. package/packages/visualizer/src/evidence/outcome-summary.tsx +27 -0
  39. package/packages/visualizer/src/evidence/outcome.ts +7 -2
  40. package/packages/visualizer/src/explorer/ArtifactsView.tsx +19 -5
  41. package/packages/visualizer/src/explorer/Explorer.tsx +7 -2
  42. package/packages/visualizer/src/explorer/RecordingTextSearch.tsx +355 -0
  43. package/packages/visualizer/src/explorer/TestDetail.tsx +2 -2
  44. package/packages/visualizer/src/explorer/collection-model.test.ts +31 -0
  45. package/packages/visualizer/src/explorer/collection-model.ts +45 -0
  46. package/packages/visualizer/src/explorer/collections.tsx +513 -0
  47. package/packages/visualizer/src/explorer/playlist-entries.ts +29 -0
  48. package/packages/visualizer/src/explorer/playlist.tsx +164 -37
  49. package/packages/visualizer/src/explorer/recording-text.test.ts +149 -0
  50. package/packages/visualizer/src/explorer/recording-text.ts +490 -0
  51. package/packages/visualizer/src/explorer/search.ts +17 -3
@@ -7,22 +7,26 @@
7
7
  * untagged. Each row opens that exact result in the test detail. An empty answer for a filter says nothing matches and
8
8
  * offers to clear it; it never says the project has nothing recorded.
9
9
  */
10
- import { IconPlayerPlay, IconVideo } from '@tabler/icons-react';
11
- import { Alert, Anchor, Box, Button, Code, Stack, Table, Text } from '@mantine/core';
10
+ import { IconLock, IconPlayerPlay, IconVideo } from '@tabler/icons-react';
11
+ import { Alert, Anchor, Box, Button, Code, Group, Stack, Table, Text } from '@mantine/core';
12
12
  import { videoCounts } from '@crvouga/atlas/reports/evidence-detail';
13
13
  import { environmentLabel } from '@crvouga/atlas/reports/layer-axes';
14
14
  import { Link } from '@tanstack/react-router';
15
15
  import { useMemo, useState } from 'react';
16
16
 
17
+ import { staticEvidence } from '../evidence/api';
17
18
  import { ago, exactTime, plural, shortSha, statusWord, useNow } from '../evidence/format';
18
19
  import { StatusMark } from '../evidence/marks';
19
20
  import { useArtifactList } from './catalog';
21
+ import { FreezeCollection } from './collections';
20
22
  import { FilterBar } from './filters';
21
23
  import { patched, useExplorerNavigate, useExplorerSearch } from './navigate';
22
24
  import { runName } from './parts';
23
25
  import { entriesFromArtifacts } from './playlist-entries';
24
26
  import { WatchPlaylist } from './playlist';
27
+ import { RecordingTextSearch } from './RecordingTextSearch';
25
28
  import { filtered, filterOf, NO_FILTERS } from './search';
29
+ import { filterParams } from '@crvouga/atlas/reports/artifact-query';
26
30
 
27
31
  const ARTIFACT_ROWS = 50;
28
32
 
@@ -36,6 +40,7 @@ export function ArtifactsView() {
36
40
  const now = useNow();
37
41
  const [shown, setShown] = useState(ARTIFACT_ROWS);
38
42
  const [watching, setWatching] = useState(false);
43
+ const [freezing, setFreezing] = useState(false);
39
44
  const entries = useMemo(() => (answer.data && !answer.data.unsupported ? entriesFromArtifacts(answer.data.list.results) : []), [answer.data]);
40
45
  if (answer.isPending)
41
46
  return (
@@ -82,11 +87,20 @@ export function ArtifactsView() {
82
87
  </Stack>
83
88
  )}
84
89
  {list.results.length > 0 && (
85
- <Button type="button" variant="filled" my="sm" leftSection={<IconPlayerPlay size={16} aria-hidden="true" />} onClick={() => setWatching(true)}>
86
- Watch these {plural(list.results.length, 'result', 'results')}
87
- </Button>
90
+ <Group my="sm" gap="sm">
91
+ <Button type="button" variant="filled" leftSection={<IconPlayerPlay size={16} aria-hidden="true" />} onClick={() => setWatching(true)}>
92
+ Watch these {plural(list.results.length, 'result', 'results')}
93
+ </Button>
94
+ {!staticEvidence && (
95
+ <Button type="button" leftSection={<IconLock size={16} aria-hidden="true" />} onClick={() => setFreezing(true)}>
96
+ Freeze as review collection
97
+ </Button>
98
+ )}
99
+ </Group>
88
100
  )}
89
101
  {watching && <WatchPlaylist entries={entries} total={list.total} onClose={() => setWatching(false)} />}
102
+ {freezing && <FreezeCollection results={list.results} total={list.total} filterQuery={filterParams(filter).toString()} onClose={() => setFreezing(false)} />}
103
+ {list.results.length > 0 && <RecordingTextSearch entries={entries} total={list.total} />}
90
104
  {list.results.length > 0 && (
91
105
  <Table.ScrollContainer minWidth={700}>
92
106
  <Table data-artifacts>
@@ -44,6 +44,7 @@ import {
44
44
  } from '@crvouga/atlas/reports/test-catalog';
45
45
  import { Link } from '@tanstack/react-router';
46
46
  import { useDeferredValue, useEffect, useMemo, useState } from 'react';
47
+ import { staticEvidence } from '../evidence/api';
47
48
  import { useEvidence } from '../evidence/evidence-context';
48
49
  import { ago, plural, useNow } from '../evidence/format';
49
50
  import { Meter, StatusMark } from '../evidence/marks';
@@ -57,6 +58,7 @@ import { groupTests, tally, type TestGroupView } from './groups';
57
58
  import { patched, useExplorerNavigate, useExplorerSearch } from './navigate';
58
59
  import { Badges, EnvironmentChips, projectTitle, runName } from './parts';
59
60
  import { ArtifactsView } from './ArtifactsView';
61
+ import { CollectionsView } from './collections';
60
62
  import { RunsView } from './RunsView';
61
63
  import { SearchNotes, TypedFilters } from './search-notes';
62
64
  import { EXPLORER_SORTS, joined, narrowed, queryOf, sortOf, TEST_GROUPS, toggled, values, type ExplorerSearch, type ExplorerSort, type TestGroup } from './search';
@@ -167,7 +169,7 @@ export function Explorer() {
167
169
  const model = catalog.model;
168
170
  const selected = search.test ? (model?.byKey.get(search.test) ?? null) : null;
169
171
  const empty = model !== null && model.tests.length === 0;
170
- const split = !!search.test && search.tab !== 'runs' && search.tab !== 'artifacts';
172
+ const split = !!search.test && search.tab !== 'runs' && search.tab !== 'artifacts' && search.tab !== 'collections';
171
173
  return (
172
174
  <Container size="xl" py="xl">
173
175
  <Box aria-label="Test explorer" component="article" miw={0}>
@@ -178,7 +180,7 @@ export function Explorer() {
178
180
  {model && (model.tests.length > 0 || model.summary.runs > 0) && (
179
181
  <Tabs
180
182
  value={search.tab ?? 'tests'}
181
- onChange={(value) => go({ tab: value === 'runs' || value === 'artifacts' ? value : undefined, test: undefined, openRun: undefined, layer: undefined, artifact: undefined })}
183
+ onChange={(value) => go({ tab: value === 'runs' || value === 'artifacts' || value === 'collections' ? value : undefined, test: undefined, openRun: undefined, layer: undefined, artifact: undefined, collection: undefined, rev: undefined })}
182
184
  >
183
185
  <Tabs.List aria-label="Explorer view">
184
186
  <Tabs.Tab
@@ -202,6 +204,7 @@ export function Explorer() {
202
204
  Runs
203
205
  </Tabs.Tab>
204
206
  <Tabs.Tab value="artifacts">Artifacts</Tabs.Tab>
207
+ {!staticEvidence && <Tabs.Tab value="collections">Collections</Tabs.Tab>}
205
208
  </Tabs.List>
206
209
  </Tabs>
207
210
  )}
@@ -266,6 +269,8 @@ export function Explorer() {
266
269
  <RunsView />
267
270
  ) : search.tab === 'artifacts' ? (
268
271
  <ArtifactsView />
272
+ ) : search.tab === 'collections' ? (
273
+ <CollectionsView />
269
274
  ) : empty ? null : (
270
275
  <>
271
276
  <InsightStrip model={model} scope={listing.scope} search={search} />
@@ -0,0 +1,355 @@
1
+ /**
2
+ * @module Search inside recordings
3
+ *
4
+ * Finds words that were spoken or shown on screen in the recordings of the Artifacts tab, from the text their producers
5
+ * supplied (WebVTT, SubRip, timed JSON, a transcript; see `recording-text.ts`). It is the shared search (`find/`) applied
6
+ * to the indexed segments, so phrases, misspellings, filters typed in the box and meaning-based matches behave as in every
7
+ * other search box, and a result always shows the words as indexed, never a reworded quotation.
8
+ *
9
+ * Nothing is read until words are typed. Then, a few at a time, each listed result's evidence is read through the same
10
+ * report API that plays its recording, and so under the same access rules: a file the viewer may not read is never
11
+ * indexed, appears in no snippet or count, and is reported as a recording that could not be searched. The index lives
12
+ * in this page only; there is no stored or cross-project index. "No match" is said only about recordings whose text was
13
+ * searched: recordings with no text, silent ones and unreadable text are listed apart, each in its own words. A
14
+ * match finds a moment; it says nothing about whether the behavior passed, and the result's own outcome stays beside it.
15
+ */
16
+ import { Accordion, Alert, Anchor, Badge, Button, Group, Highlight, List, Stack, Table, Text, TextInput, Title } from '@mantine/core';
17
+ import { IconPlayerPlay } from '@tabler/icons-react';
18
+ import { useQueryClient } from '@tanstack/react-query';
19
+ import { Link } from '@tanstack/react-router';
20
+ import { useEffect, useMemo, useRef, useState } from 'react';
21
+
22
+ import { ReportsError, reportText } from '../evidence/api';
23
+ import { attachmentSource } from '../evidence/artifacts';
24
+ import { plural, statusWord } from '../evidence/format';
25
+ import { StatusMark } from '../evidence/marks';
26
+ import { useDocSearch } from '../find/doc-search';
27
+ import { passesFilters } from '../find/query';
28
+ import type { Ranked } from '../find/rank';
29
+ import { patched, useExplorerNavigate, useExplorerSearch } from './navigate';
30
+ import { entryDetailQuery, WatchPlaylist } from './playlist';
31
+ import { recordingState, type PlaylistEntry } from './playlist-entries';
32
+ import {
33
+ coverageCounts,
34
+ coverageOf,
35
+ focusOf,
36
+ indexTrack,
37
+ rangeWord,
38
+ segmentDoc,
39
+ segmentsOf,
40
+ TEXT_KIND_WORD,
41
+ tracksOf,
42
+ trackReadable,
43
+ uncertaintyWord,
44
+ unreadTrack,
45
+ type IndexedSegment,
46
+ type RecordingFocus,
47
+ type TrackResult,
48
+ type VideoCoverage
49
+ } from './recording-text';
50
+ import { SearchNotes, TypedFilters } from './search-notes';
51
+ import type { CheckDetail } from '@crvouga/atlas/reports/evidence-detail';
52
+
53
+ /** Results listed before "more". */
54
+ const LISTED = 10;
55
+ /** Results read at once. */
56
+ const READERS = 4;
57
+
58
+ /** What was learned about one result's recordings. */
59
+ type Read =
60
+ | { readonly state: 'read'; readonly videos: readonly VideoCoverage[]; readonly segments: readonly IndexedSegment[] }
61
+ | { readonly state: 'unreadable'; readonly reason: string };
62
+
63
+ function problem(error: unknown): string {
64
+ if (error instanceof ReportsError) {
65
+ if (error.status === 401 || error.status === 403) return 'You are not permitted to read this, so it is not searched.';
66
+ if (error.status === 404) return 'It is not in the report store, so it is not searched.';
67
+ }
68
+ return error instanceof Error ? error.message : 'It could not be read.';
69
+ }
70
+
71
+ /**
72
+ * Reads, once `enabled`, the text tracks of each result that has a recording, a few results at a time. What is read lands
73
+ * as it arrives; `read` of `total` say how far it is.
74
+ */
75
+ function useRecordingText(entries: readonly PlaylistEntry[], enabled: boolean) {
76
+ const client = useQueryClient();
77
+ const cache = useRef(new Map<string, Read>());
78
+ const [reads, setReads] = useState<ReadonlyMap<string, Read>>(() => new Map());
79
+ const playable = useMemo(() => entries.filter((entry) => recordingState(entry) === 'playable' && entryDetailQuery(entry) !== null), [entries]);
80
+ const key = playable.map((entry) => entry.id).join('\n');
81
+ useEffect(() => {
82
+ if (!enabled) return;
83
+ let cancelled = false;
84
+ const wanted = playable.filter((entry) => !cache.current.has(entry.id));
85
+ let cursor = 0;
86
+ const readTrack = async (entry: PlaylistEntry, track: ReturnType<typeof tracksOf>[number]): Promise<TrackResult> => {
87
+ const readable = trackReadable(track);
88
+ if (!readable.readable) return { track, state: readable.state, reason: readable.reason, segments: [], timing: 'absent', truncated: false };
89
+ const attachment = track.attachment;
90
+ if (attachment.text !== undefined) return indexTrack(track, attachment.text);
91
+ const path = attachmentSource(attachment, entry.layer).path;
92
+ if (!path) return unreadTrack(track, 'The text names no stored file.');
93
+ try {
94
+ return indexTrack(track, await client.fetchQuery({ queryKey: ['artifact-text', path], queryFn: ({ signal }) => reportText(path, signal), staleTime: Number.POSITIVE_INFINITY, retry: false }));
95
+ } catch (error) {
96
+ return unreadTrack(track, problem(error));
97
+ }
98
+ };
99
+ const readEntry = async (entry: PlaylistEntry): Promise<Read> => {
100
+ let detail: CheckDetail;
101
+ try {
102
+ detail = await client.fetchQuery(entryDetailQuery(entry)!);
103
+ } catch (error) {
104
+ return { state: 'unreadable', reason: `Its evidence could not be read. ${problem(error)}` };
105
+ }
106
+ const results: TrackResult[] = [];
107
+ for (const track of tracksOf(detail)) results.push(await readTrack(entry, track));
108
+ const videos = coverageOf(detail, results);
109
+ return { state: 'read', videos, segments: segmentsOf(entry, detail, results, videos) };
110
+ };
111
+ const pull = async () => {
112
+ for (;;) {
113
+ const entry = wanted[cursor++];
114
+ if (entry === undefined || cancelled) return;
115
+ const done = await readEntry(entry);
116
+ if (cancelled) return;
117
+ cache.current.set(entry.id, done);
118
+ setReads(new Map(cache.current));
119
+ }
120
+ };
121
+ void Promise.all(Array.from({ length: READERS }, pull));
122
+ return () => {
123
+ cancelled = true;
124
+ };
125
+ }, [client, enabled, key, playable]);
126
+ const read = useMemo(() => playable.filter((entry) => reads.has(entry.id)).length, [playable, reads]);
127
+ return { reads, playable, read, total: playable.length };
128
+ }
129
+
130
+ /** Why a segment is in the results, in words that do not claim more than was matched. */
131
+ function why(hit: Ranked): string {
132
+ if (hit.exact && hit.literals?.length) return `Exact: ${hit.literals.map((literal) => `“${literal}”`).join(', ')}`;
133
+ if (hit.semantic) return 'Similar meaning to the indexed words';
134
+ if (hit.fuzzy) return 'Close spelling of the indexed words';
135
+ if (hit.partial) return 'Some of the words are in the indexed text';
136
+ return 'The words are in the indexed text';
137
+ }
138
+
139
+ const COVERAGE_WORD = { 'not-indexed': 'Not indexed', unsupported: 'Unsupported', failed: 'Failed', searched: 'Searched' } as const;
140
+ const COVERAGE_COLOR = { 'not-indexed': 'gray', unsupported: 'orange', failed: 'red', searched: 'green' } as const;
141
+
142
+ /** A recording that was not searched, and why. */
143
+ type Unsearched = { readonly key: string; readonly entry: PlaylistEntry; readonly name: string | null; readonly attempt: string; readonly state: 'not-indexed' | 'unsupported' | 'failed'; readonly reason: string };
144
+
145
+ /** The Artifacts tab's search of the words in its recordings. `entries` are the listed results; `total` how many matched the filters. */
146
+ export function RecordingTextSearch({ entries, total }: { entries: readonly PlaylistEntry[]; total: number }) {
147
+ const search = useExplorerSearch();
148
+ const go = useExplorerNavigate();
149
+ const [draft, setDraft] = useState(search.inside ?? '');
150
+ useEffect(() => setDraft((current) => (current.trim() === (search.inside ?? '').trim() ? current : (search.inside ?? ''))), [search.inside]);
151
+ const [shown, setShown] = useState(LISTED);
152
+ const [opened, setOpened] = useState<RecordingFocus | null>(null);
153
+ const query = search.inside;
154
+ const { reads, playable, read, total: readable } = useRecordingText(entries, !!query);
155
+ const items = useMemo(() => [...reads.values()].flatMap((item) => (item.state === 'read' ? item.segments : [])), [reads]);
156
+ const docs = useMemo(() => items.map(segmentDoc), [items]);
157
+ const byId = useMemo(() => new Map(items.map((item) => [item.id, item] as const)), [items]);
158
+ const found = useDocSearch(docs, query);
159
+ const matches = useMemo(() => (found.hits ? [...found.hits.values()].filter((hit) => passesFilters(hit.doc.facets, found.parsed.filters)) : null), [found.hits, found.parsed.filters]);
160
+ const unsearched = useMemo<Unsearched[]>(() => {
161
+ const out: Unsearched[] = [];
162
+ for (const entry of playable) {
163
+ const item = reads.get(entry.id);
164
+ if (!item) continue;
165
+ if (item.state === 'unreadable') out.push({ key: entry.id, entry, name: null, attempt: '', state: 'failed', reason: item.reason });
166
+ else
167
+ for (const video of item.videos)
168
+ if (video.state !== 'searched') out.push({ key: `${entry.id}|${video.name}|${video.attempt}`, entry, name: video.name, attempt: video.attempt === null ? '' : `Attempt ${video.attempt + 1}`, state: video.state, reason: video.reason });
169
+ // A recording the evidence lists no video for, though the catalog does, is counted as not indexed.
170
+ if (item.state === 'read' && item.videos.length === 0) out.push({ key: `${entry.id}|none`, entry, name: null, attempt: '', state: 'not-indexed', reason: 'The recording could not be matched to any text.' });
171
+ }
172
+ return out;
173
+ }, [playable, reads]);
174
+ const videos = useMemo(() => [...reads.values()].flatMap((item) => (item.state === 'read' ? item.videos : [])), [reads]);
175
+ const counts = useMemo(() => coverageCounts(videos), [videos]);
176
+ const waiting = !!query && read < readable;
177
+ const noRecording = entries.length - playable.length;
178
+ const results = matches?.slice(0, shown) ?? [];
179
+ const resultEntries = new Set((matches ?? []).map((hit) => byId.get(hit.doc.id)?.entry.id));
180
+ const set = (value: string) => {
181
+ setDraft(value);
182
+ setShown(LISTED);
183
+ go({ inside: value.trim() || undefined }, { replace: true });
184
+ };
185
+ return (
186
+ <Stack component="section" aria-label="Search inside recordings" gap="xs" my="md" data-recording-text>
187
+ <Title order={2}>Search inside recordings</Title>
188
+ <Text c="dimmed">
189
+ Find words that were spoken or shown on screen, from the transcripts, captions and screen text that producers supplied with the recordings listed here. A match finds a moment; it does not show that the behavior
190
+ passed.
191
+ </Text>
192
+ <Group gap="xs" align="center" wrap="wrap">
193
+ <TextInput type="search" aria-label="Words spoken or shown" placeholder="delivery address" value={draft} miw={{ base: '100%', sm: '20rem' }} onChange={(event) => set(event.target.value)} />
194
+ <TypedFilters parsed={found.parsed} query={draft} onChange={set} />
195
+ </Group>
196
+ <SearchNotes issues={found.parsed.issues} semantic={found.status} />
197
+ {!query && (
198
+ <Text component="div" c="dimmed">
199
+ Type words to search. Nothing is read from the recordings until you do, and only the text supplied with them is read, never the video.
200
+ </Text>
201
+ )}
202
+ {query && (
203
+ <Stack gap="xs">
204
+ <Text role="status" component="div" data-reading>
205
+ {waiting
206
+ ? `Reading the text of recordings: ${read} of ${readable}.`
207
+ : `Searched the text of ${plural(counts.searched, 'recording', 'recordings')}.`}
208
+ {total > entries.length ? ` Only the newest ${entries.length} of ${total.toLocaleString()} results are listed, so only they are searched.` : ''}
209
+ {noRecording > 0 ? ` ${plural(noRecording, 'result has', 'results have')} no playable recording and ${noRecording === 1 ? 'is' : 'are'} not searched.` : ''}
210
+ </Text>
211
+ <Group gap="xs" aria-label="Coverage" role="group" data-coverage>
212
+ {(['searched', 'not-indexed', 'unsupported', 'failed'] as const).map((state) => (
213
+ <Badge key={state} color={COVERAGE_COLOR[state]} variant="light" data-coverage-state={state}>
214
+ {COVERAGE_WORD[state]}: {counts[state] + (state === 'failed' ? unsearched.filter((item) => item.name === null && item.state === 'failed').length : 0)}
215
+ </Badge>
216
+ ))}
217
+ </Group>
218
+ </Stack>
219
+ )}
220
+ {query && matches === null && (
221
+ <Text component="div" c="dimmed">
222
+ Add words to search for. Filters alone do not match any text.
223
+ </Text>
224
+ )}
225
+ {query && matches && matches.length === 0 && (
226
+ <Alert role="status" color={waiting ? 'blue' : 'gray'} data-no-match={waiting ? undefined : true}>
227
+ {waiting
228
+ ? 'No match yet. Some recordings are still being read, so this is not the final answer.'
229
+ : counts.searched === 0
230
+ ? 'Nothing was searched: no listed recording has text that can be searched. Recordings without it are listed below.'
231
+ : `No indexed text matches. ${plural(counts.searched, 'recording was', 'recordings were')} searched.${unsearched.length > 0 ? ` ${plural(unsearched.length, 'recording was', 'recordings were')} not searched (listed below), so this says nothing about ${unsearched.length === 1 ? 'it' : 'them'}.` : ''}`}
232
+ </Alert>
233
+ )}
234
+ {matches && matches.length > 0 && (
235
+ <Stack gap="xs">
236
+ <Text role="status" component="div" data-match-count>
237
+ {plural(matches.length, 'match', 'matches')} in {plural(resultEntries.size, 'result', 'results')}. The outcome is the result&apos;s own; a match does not change it.
238
+ </Text>
239
+ <Table.ScrollContainer minWidth={760}>
240
+ <Table data-recording-matches>
241
+ <Table.Thead>
242
+ <Table.Tr>
243
+ <Table.Th scope="col">Indexed text</Table.Th>
244
+ <Table.Th scope="col">Source</Table.Th>
245
+ <Table.Th scope="col">Time</Table.Th>
246
+ <Table.Th scope="col">Result</Table.Th>
247
+ <Table.Th scope="col">Outcome</Table.Th>
248
+ <Table.Th scope="col">Open</Table.Th>
249
+ </Table.Tr>
250
+ </Table.Thead>
251
+ <Table.Tbody>
252
+ {results.map((hit) => {
253
+ const item = byId.get(hit.doc.id);
254
+ if (!item) return null;
255
+ return (
256
+ <Table.Tr key={hit.doc.id} data-match={hit.doc.id} data-source-kind={item.kind} data-status={item.entry.status}>
257
+ <Table.Td maw="28rem">
258
+ <Highlight highlight={[...found.parsed.words, ...found.parsed.phrases]} component="div">
259
+ {item.segment.text}
260
+ </Highlight>
261
+ <Text c="dimmed" size="xs" data-why>
262
+ {why(hit)}
263
+ </Text>
264
+ </Table.Td>
265
+ <Table.Td>
266
+ <Text component="div">{TEXT_KIND_WORD[item.kind]}</Text>
267
+ {item.generated && (
268
+ <Text c="orange.8" size="xs" component="div" data-generated>
269
+ {uncertaintyWord(item)}
270
+ </Text>
271
+ )}
272
+ {!item.generated && item.segment.confidence !== undefined && (
273
+ <Text c="dimmed" size="xs" component="div">
274
+ Confidence {Math.round(item.segment.confidence * 100)}%
275
+ </Text>
276
+ )}
277
+ </Table.Td>
278
+ <Table.Td data-time>{rangeWord(item)}</Table.Td>
279
+ <Table.Td>
280
+ <Anchor
281
+ aria-label={`Open ${item.entry.title} as recorded`}
282
+ renderRoot={(props) => (
283
+ <Link {...props} to="/" search={patched({ tab: undefined, openRun: undefined, test: item.entry.key, layer: item.entry.layer })} resetScroll={false} activeOptions={{ exact: true }} />
284
+ )}
285
+ >
286
+ {item.entry.title}
287
+ </Anchor>
288
+ <Text c="dimmed" size="xs" component="div">
289
+ {[item.entry.producer, item.videoName, item.attemptLabel].filter(Boolean).join(' · ')}
290
+ </Text>
291
+ </Table.Td>
292
+ <Table.Td>
293
+ <StatusMark status={item.entry.status} size={14} /> {statusWord(item.entry.status)}
294
+ </Table.Td>
295
+ <Table.Td>
296
+ <Button
297
+ type="button"
298
+ size="compact-sm"
299
+ variant="light"
300
+ leftSection={<IconPlayerPlay size={16} aria-hidden="true" />}
301
+ aria-label={`Open the recording of ${item.entry.title}${item.attemptLabel ? `, ${item.attemptLabel}` : ''} at ${rangeWord(item)}`}
302
+ onClick={() => setOpened(focusOf(item))}
303
+ >
304
+ {item.segment.startMs === null ? 'Open recording' : 'Open here'}
305
+ </Button>
306
+ </Table.Td>
307
+ </Table.Tr>
308
+ );
309
+ })}
310
+ </Table.Tbody>
311
+ </Table>
312
+ </Table.ScrollContainer>
313
+ {matches.length > results.length && (
314
+ <Button type="button" variant="subtle" onClick={() => setShown(shown + LISTED)}>
315
+ Show more ({matches.length - results.length} more)
316
+ </Button>
317
+ )}
318
+ </Stack>
319
+ )}
320
+ {query && unsearched.length > 0 && (
321
+ <Accordion variant="separated">
322
+ <Accordion.Item value="unsearched">
323
+ <Accordion.Control>Recordings that were not searched ({unsearched.length})</Accordion.Control>
324
+ <Accordion.Panel>
325
+ <Text c="dimmed" mb="xs">
326
+ These are not &quot;no match&quot;: their words were never searched.
327
+ </Text>
328
+ <List spacing="xs" listStyleType="none" aria-label="Recordings that were not searched">
329
+ {unsearched.map((item) => (
330
+ <List.Item key={item.key} data-unsearched={item.state}>
331
+ <Group gap="xs" wrap="wrap">
332
+ <Badge color={COVERAGE_COLOR[item.state]} variant="light">
333
+ {COVERAGE_WORD[item.state]}
334
+ </Badge>
335
+ <Text span inherit fw={600}>
336
+ {item.entry.title}
337
+ </Text>
338
+ <Text span inherit c="dimmed">
339
+ {[item.name, item.attempt].filter(Boolean).join(' · ')}
340
+ </Text>
341
+ </Group>
342
+ <Text c="dimmed" size="sm">
343
+ {item.reason}
344
+ </Text>
345
+ </List.Item>
346
+ ))}
347
+ </List>
348
+ </Accordion.Panel>
349
+ </Accordion.Item>
350
+ </Accordion>
351
+ )}
352
+ {opened && <WatchPlaylist entries={entries} total={total} focus={opened} onClose={() => setOpened(null)} />}
353
+ </Stack>
354
+ );
355
+ }
@@ -35,7 +35,7 @@ function LatestLink({ children }: { children: string }) {
35
35
  return (
36
36
  <Anchor
37
37
  renderRoot={(props) => (
38
- <Link {...props} to="/" search={patched({ layer: undefined })} resetScroll={false} activeOptions={{ exact: true }} />
38
+ <Link {...props} to="/" search={patched({ layer: undefined, clip: undefined, clipAt: undefined })} resetScroll={false} activeOptions={{ exact: true }} />
39
39
  )}
40
40
  >
41
41
  {children}
@@ -168,7 +168,7 @@ export function TestDetail({ test, slot }: { test: TestSummary; slot: CheckSlot
168
168
  <Anchor
169
169
  aria-label="Close the test"
170
170
  renderRoot={(props) => (
171
- <Link {...props} to="/" search={patched({ test: undefined, layer: undefined })} resetScroll={false} activeOptions={{ exact: true }} />
171
+ <Link {...props} to="/" search={patched({ test: undefined, layer: undefined, clip: undefined, clipAt: undefined })} resetScroll={false} activeOptions={{ exact: true }} />
172
172
  )}
173
173
  >
174
174
  <IconX size={20} aria-hidden="true" />
@@ -0,0 +1,31 @@
1
+ import { describe, expect, it } from 'bun:test';
2
+
3
+ import { formatTimecode, parseTagsField, parseTimecode, sourceSpread } from './collection-model';
4
+
5
+ describe('review collection helpers', () => {
6
+ it('reads a moment as seconds, minutes and seconds, or hours; empty is no time and a mistake is told apart', () => {
7
+ expect(parseTimecode('75')).toBe(75_000);
8
+ expect(parseTimecode('1:15')).toBe(75_000);
9
+ expect(parseTimecode('1:02:03')).toBe(3_723_000);
10
+ expect(parseTimecode('0:01.5')).toBe(1_500);
11
+ expect(parseTimecode(' ')).toBeNull();
12
+ for (const bad of ['abc', '1:xx', '-3', '1:2:3:4', '1::2']) expect(parseTimecode(bad)).toBeUndefined();
13
+ });
14
+
15
+ it('writes a moment the way it is read back', () => {
16
+ expect(formatTimecode(75_000)).toBe('1:15');
17
+ expect(formatTimecode(3_723_000)).toBe('1:02:03');
18
+ expect(formatTimecode(0)).toBe('0:00');
19
+ for (const ms of [0, 1_000, 59_000, 3_599_000, 7_200_000]) expect(parseTimecode(formatTimecode(ms))).toBe(ms);
20
+ });
21
+
22
+ it('counts distinct runs and layers, so entries from several executions never read as one', () => {
23
+ const entry = (layer: string, run: string) => ({ id: layer, source: { layer, check: 'c' }, snapshot: { run } }) as never;
24
+ expect(sourceSpread({ entries: [entry('a', 'r1'), entry('b', 'r2'), entry('c', 'r2')] })).toEqual({ runs: 2, layers: 3 });
25
+ expect(sourceSpread({ entries: [] })).toEqual({ runs: 0, layers: 0 });
26
+ });
27
+
28
+ it('splits a tags field on commas and spaces', () => {
29
+ expect(parseTagsField('demo, #wip demo')).toEqual(['demo', '#wip']);
30
+ });
31
+ });
@@ -0,0 +1,45 @@
1
+ /**
2
+ * @module Review collection model
3
+ *
4
+ * The small pure parts of the Collections tab: reading and writing the time of a note in a recording (`1:05`), how many
5
+ * distinct runs and layers a collection's entries come from (so the page never lets entries from several executions read
6
+ * as one), and the tags field. The page is `collections.tsx`.
7
+ */
8
+ import type { ReviewCollection } from '@crvouga/atlas/reports/review-collection';
9
+
10
+ /**
11
+ * A moment in a recording as the person typed it, in milliseconds: `75` is seconds, `1:15` minutes and seconds, `1:02:03`
12
+ * hours too. Empty is no time (`null`, valid); anything else that is not a time is `undefined`, so a mistake is told apart
13
+ * from no time. Fractions of a second are kept (`1:15.5`).
14
+ */
15
+ export function parseTimecode(raw: string): number | null | undefined {
16
+ const value = raw.trim();
17
+ if (value === '') return null;
18
+ const parts = value.split(':');
19
+ if (parts.length > 3 || parts.some((part) => !/^\d+(\.\d+)?$/.test(part))) return undefined;
20
+ const seconds = parts.reduce((total, part) => total * 60 + Number(part), 0);
21
+ return Math.round(seconds * 1000);
22
+ }
23
+
24
+ /** A moment in a recording for a person: `1:15`, or `1:02:03` from an hour. */
25
+ export function formatTimecode(ms: number): string {
26
+ const total = Math.max(0, Math.floor(ms / 1000));
27
+ const hours = Math.floor(total / 3600);
28
+ const minutes = Math.floor((total % 3600) / 60);
29
+ const seconds = total % 60;
30
+ const two = (value: number) => String(value).padStart(2, '0');
31
+ return hours > 0 ? `${hours}:${two(minutes)}:${two(seconds)}` : `${minutes}:${two(seconds)}`;
32
+ }
33
+
34
+ /** How many distinct runs and layers (executions) a collection's entries were recorded in. More than one run means the collection is not one execution. */
35
+ export function sourceSpread(collection: Pick<ReviewCollection, 'entries'>): { readonly runs: number; readonly layers: number } {
36
+ return {
37
+ runs: new Set(collection.entries.map((entry) => entry.snapshot.run)).size,
38
+ layers: new Set(collection.entries.map((entry) => entry.source.layer)).size
39
+ };
40
+ }
41
+
42
+ /** The tags typed in a field (commas or spaces apart), without repeats; the server normalizes them. */
43
+ export function parseTagsField(raw: string): string[] {
44
+ return [...new Set(raw.split(/[\s,]+/).map((tag) => tag.trim()).filter(Boolean))];
45
+ }