From d3e4127d18d9716804f4fc367b655765cbaa73bd Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Sun, 23 Aug 2026 15:23:14 +0200 Subject: [PATCH] feat(console): make the recordings list a list again A card on /recordings was a container for a multi-track player, a metadata block and three buttons, and it expanded in place. That made every card a worse card in order to be a mediocre player, and a page of them unscannable. It is a row now. One bordered surface with hairline rules, a date column down the left edge, and the whole row is the link to the recording. Nothing plays here: audio, spectrograms, per-track transports and the protocol link all live on the recording's own page, where there is room for them. What a row keeps is what tells two meetings apart -- when, where, who and how long -- plus the two things the filter above asks about and could otherwise not be checked against the results: whether a protocol exists, and the reader's own tags. What it drops is the player, the Listen/Close toggle, the separate Open link, and the enumerated list of everybody who was in the channel without consenting. An unresolved channel no longer reads as a name. `channelLabel` answers "Channel 1240377558927872021", and eighteen digits set as a heading are what made the old cards unreadable; `channelNaming` heads the row with an absence instead, in the muted role and in normal weight, and demotes the id to a subordinate line -- kept, because it is the only handle anybody debugging a channel that has left the guild has. The tag buttons and the search box become one `UiChipInput`: chips out as `?tag=`, words out as `?q=`, the line between them kept in the value. The dates become `UiDatePicker` and the protocol control `UiSelect`. `UiDatePicker` grows a `granularity` prop for that. A recordings filter is a pair of inclusive UTC days -- `sturnus.console.filters` says so and reads them with `date.fromisoformat` -- and an instant here would claim a precision no request carries and, being the browser's offset, would differ from the one a server render attached, on exactly the filtered links this page exists to make shareable. Every state stays: error, first-load skeleton, past-the-end, no-match, empty, and the live-region page summary. --- console/app/components/RecordingRow.vue | 163 ++++++++++++ console/app/components/RecordingSession.vue | 164 ------------ .../app/components/RecordingsFilterBar.vue | 242 ++++++++++-------- console/app/components/ui/UiDatePicker.vue | 72 ++++-- console/app/pages/dev/ui.vue | 13 + console/app/pages/recordings/index.vue | 87 ++++--- console/app/utils/recordingFilters.ts | 37 ++- console/app/utils/recordings.ts | 114 ++++++++- console/app/utils/uiDatePicker.ts | 81 ++++++ console/i18n/locales/de.json | 18 +- console/i18n/locales/en.json | 18 +- console/test/recordingFilters.spec.ts | 52 +++- console/test/recordingRow.spec.ts | 180 +++++++++++++ console/test/recordings.spec.ts | 107 ++++++++ console/test/uiComponents.spec.ts | 28 ++ console/test/uiDatePicker.spec.ts | 86 +++++++ 16 files changed, 1083 insertions(+), 379 deletions(-) create mode 100644 console/app/components/RecordingRow.vue delete mode 100644 console/app/components/RecordingSession.vue create mode 100644 console/test/recordingRow.spec.ts diff --git a/console/app/components/RecordingRow.vue b/console/app/components/RecordingRow.vue new file mode 100644 index 0000000..d273f01 --- /dev/null +++ b/console/app/components/RecordingRow.vue @@ -0,0 +1,163 @@ + + + diff --git a/console/app/components/RecordingSession.vue b/console/app/components/RecordingSession.vue deleted file mode 100644 index a18a47f..0000000 --- a/console/app/components/RecordingSession.vue +++ /dev/null @@ -1,164 +0,0 @@ - - - diff --git a/console/app/components/RecordingsFilterBar.vue b/console/app/components/RecordingsFilterBar.vue index 8dd4dae..4852b80 100644 --- a/console/app/components/RecordingsFilterBar.vue +++ b/console/app/components/RecordingsFilterBar.vue @@ -15,21 +15,36 @@ * arrive out of order. Pressing Enter or "Search" is one request, at a * moment the reader chose. * - * **What it searches is stated on the page.** The API matches the - * channel, the people who were in a session and the reader's own tags, - * and never a transcript — a decision about other people's speech, made - * in `sturnus.console.filters`. A search box that does not say what it - * searches leaves people assuming it does more, and here the wrong - * assumption is that Sturnus will find them a phrase somebody said. + * **Tags and words are one field now.** They were two mechanisms for one + * question — a row of toggle buttons, and a box beside it — and the + * question people actually ask is `#standup #migration the bit where the + * database fell over`, which everybody typed into the box. `UiChipInput` + * holds both, and keeps the line between them in the value rather than + * only on screen, so the chips go out as `?tag=` and the words as `?q=` + * without anything here parsing a tag back out of a sentence. The + * translation is `~/utils/recordingFilters`. + * + * **What it searches is stated on the page, and stated so it stays true.** + * The API matches the channel, the people who were in a session and the + * reader's own tags, and never a transcript — a decision about other + * people's speech, made in `sturnus.console.filters`. The note below + * names the boundary rather than enumerating the columns, because the + * columns are due to grow and a sentence that lists three of them becomes + * quietly wrong the day a fourth is added. The half that must never + * change is stated hardest. */ import { NO_FILTERS, activeFilterLabels, + chipsFromFilters, + filtersFromChips, hasActiveFilters, - toggledTag, + type ProtocolFilter, type RecordingFilters, } from '~/utils/recordingFilters' import { TAGS_PATH, type TagsResponse } from '~/utils/tagging' +import type { ChipValue } from '~/utils/uiChipInput' +import type { UiOption } from '~/utils/uiOption' const props = defineProps<{ /** What the URL currently says. The single source of truth. */ @@ -40,6 +55,7 @@ const props = defineProps<{ const emit = defineEmits<{ apply: [filters: RecordingFilters] }>() +const { t } = useI18n() const say = useSay() /** A working copy, so that typing does not navigate on every keystroke. @@ -55,6 +71,54 @@ watch( { deep: true }, ) +/** The one field, both halves of it. Written back through the module so + * that the chips and the free text land in the fields the API reads. */ +const chips = computed({ + get: () => chipsFromFilters(draft.value), + set: (value) => { + draft.value = filtersFromChips(draft.value, value) + }, +}) + +/** + * One end of the date range, as the control speaks it. + * + * The filter writes an absent bound as the empty string, because that is + * what an untouched field holds and what `filtersToRouteQuery` drops from + * the URL. `UiDatePicker` writes it as `null`, because a control that + * emits `''` for "nothing chosen" is one every caller has to remember to + * test twice. One adapter, in one place, rather than the two branches + * that would otherwise appear at both ends of the range. + */ +function dayBound(end: 'from' | 'to') { + return computed({ + get: () => draft.value[end] || null, + set: (value) => { + draft.value = { ...draft.value, [end]: value ?? '' } + }, + }) +} + +const from = dayBound('from') +const to = dayBound('to') + +/** + * The third control, which does *not* get that adapter. + * + * "Either" is a choice this filter can express and the API reads, so it + * is an option with a value rather than the absence of one. Mapping it to + * `null` would leave the trigger reading "Choose an option" for a filter + * that is perfectly well chosen — and the reason this control exists is + * to be able to say "the ones whose document never got written", which is + * only legible next to a stated "either". + */ +const protocol = computed({ + get: () => draft.value.protocol, + set: (value) => { + draft.value = { ...draft.value, protocol: (value ?? '') as ProtocolFilter } + }, +}) + const api = useApi() /** The reader's own labels, most used first. Only ever theirs: the API * keys `session_tag` by its owner, so this list cannot contain anybody @@ -62,7 +126,19 @@ const api = useApi() const { data: tagData } = await useAsyncData('recording-tags', () => api(TAGS_PATH), ) -const offered = computed(() => tagData.value?.tags ?? []) +/** Offered as suggestions rather than laid out as a row of buttons. The + * row was a permanent inventory of somebody's whole vocabulary sitting + * above a list they were trying to read; the suggestions appear under + * the caret, when a tag is what is being typed. */ +const offered = computed(() => tagData.value?.tags.map((use) => use.tag) ?? []) + +const protocols = computed(() => [ + { value: '', label: t('recordings.protocolEither') }, + { value: 'with', label: t('recordings.protocolWritten') }, + // How you find the meeting whose document never got written, which is + // the reason this control exists. + { value: 'without', label: t('recordings.protocolNotWritten') }, +]) const active = computed(() => hasActiveFilters(props.filters)) @@ -83,24 +159,10 @@ const summary = computed(() => ({ }, })) -const searchId = useId() -const fromId = useId() -const toId = useId() -const protocolId = useId() - function submit() { emit('apply', { ...draft.value, tags: [...draft.value.tags] }) } -/** A chip applies immediately: it is one click and it has no other - * half to fill in, so making somebody press Search afterwards would be - * asking for a second click that says nothing new. */ -function toggle(tag: string) { - const next = toggledTag(props.filters, tag) - draft.value = { ...next, tags: [...next.tags] } - emit('apply', next) -} - function clear() { emit('apply', { ...NO_FILTERS, tags: [] }) } @@ -114,89 +176,73 @@ function clear() { >

{{ $t('recordings.filterHeading') }}

+
- - + +
+ +
+ +
+ + +
- - + +
+ +
- - + +
+ +
- - + +
+ +
- -
-

+ is the offset, so the offset is on screen rather than implied. + A day has no offset and therefore no note: repeating the field + back under the field is chrome, and a filter bar carrying two of + them is a filter bar nobody reads the rest of. --> +

{{ say(note) }}

diff --git a/console/app/pages/dev/ui.vue b/console/app/pages/dev/ui.vue index 0a38473..9435c1e 100644 --- a/console/app/pages/dev/ui.vue +++ b/console/app/pages/dev/ui.vue @@ -93,6 +93,10 @@ const noneSelect = ref(null) const channel = ref(null) const moment = ref(null) const boundedMoment = ref(null) +/** The other granularity. A day is what a filter over inclusive calendar + * days emits, and the whole point of seeing it here is that it carries no + * offset and no note. */ +const day = ref('2026-08-21') const chips = ref(EMPTY_CHIPS) const filledChips = ref({ chips: ['standup', 'migration'], text: 'about the ' }) const selected = ref(['row-0', 'not-on-this-page']) @@ -228,6 +232,15 @@ const page = ref(3) {{ $t('ui.gallery.emits') }}: {{ boundedMoment ?? 'null' }}

+
+

+ {{ $t('ui.gallery.stateDay') }} +

+ +

+ {{ $t('ui.gallery.emits') }}: {{ day ?? 'null' }} +

+

{{ $t('ui.gallery.stateDisabled') }} diff --git a/console/app/pages/recordings/index.vue b/console/app/pages/recordings/index.vue index 9d85c95..8ef6aa4 100644 --- a/console/app/pages/recordings/index.vue +++ b/console/app/pages/recordings/index.vue @@ -1,15 +1,23 @@