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/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') }}
+
+
+
+ {{ $t('ui.gallery.stateDay') }}
+
+
+
+ {{ $t('ui.gallery.emits') }}: {{ day ?? 'null' }}
+