+
+
+ Open this recording
+
+
+ Session status
+ {{
+ session.status || 'unknown'
+ }}
+ — asking for the transcription again is done there, where what the redo would overwrite is
+ on the screen next to the button.
+
+
+
+
diff --git a/console/app/pages/admin/queue.vue b/console/app/pages/admin/queue.vue
index 97600f2..f55f7f2 100644
--- a/console/app/pages/admin/queue.vue
+++ b/console/app/pages/admin/queue.vue
@@ -12,6 +12,23 @@
* Settings and User Settings pages use, down to the remembered choice, so
* switching servers on one admin page carries to the others.
*
+ * **The page is sections, and one of them used not to be.** Everything on
+ * it lived in a bordered `` under an `
` except the one part
+ * that grows without limit — the list of sessions, which was a bare
+ * heading followed by loose paragraphs and a stack of cards. That is why a
+ * long queue read as an endless page with nothing to navigate by, and it
+ * is also why there was nowhere to put a pager or a reordering control
+ * without hanging it off a `
`. The list is now two sections, split by
+ * the one distinction that decides what can be done to a row:
+ *
+ * - **In the queue** — a session with outstanding jobs, which the API
+ * gives a `priority`. Shown in the order a worker will reach them,
+ * reorderable, and paged.
+ * - **Listed, but not in the queue** — `priority` present and null. A
+ * meeting being recorded now, or one nothing will move on without a
+ * person. Shown in the order that ranks attention, and paged, and
+ * carrying no move handle at all, because there is nothing to move.
+ *
* Three things this page refuses to do, all three on purpose:
*
* - **It never presents a derived figure as a fact.** Three of the numbers
@@ -45,29 +62,30 @@
* not to be changing, which is exactly when somebody is deciding whether
* to believe them.
*/
+import { pageCount } from '~/utils/paging'
import {
CLEAR_QUEUE_HEADING,
CLEAR_QUEUE_NOTE,
LIFECYCLE_SCOPE_NOTE,
+ QUEUE_TONE_COLOUR,
type GuildQueue,
attentionItems,
+ claimOrderSessions,
describeQueueError,
isQueueClear,
isQueueMoving,
lifecycleFigures,
- orderQueueSessions,
parseGuildQueue,
- queueChannelLabel,
- queueChannelNote,
queuePath,
- queueSessionState,
+ queueSliceSummary,
queueStreamPath,
- sessionCounts,
- sessionStartLine,
- sessionsSummaryLine,
startQueuePolling,
truncationNotice,
+ unqueuedSessions,
+ unqueuedSummary,
} from '~/utils/queue'
+import { applyQueueOrder, type QueueOrder } from '~/utils/queueOrder'
+import { QUEUE_PAGE_SIZE, pageSlice } from '~/utils/queueReorder'
import {
describeQueueMode,
isQueueStreamFinished,
@@ -75,7 +93,6 @@ import {
type QueueStreamHandle,
type QueueStreamMode,
} from '~/utils/queueStream'
-import { recordingPath } from '~/utils/recordings'
import {
chooseGuild,
guildLabel,
@@ -141,7 +158,66 @@ const currentGuild = computed(
() => guilds.value.find((guild) => guild.id === selected.value) ?? null,
)
-const sessions = computed(() => orderQueueSessions(queue.value?.sessions ?? []))
+const say = useSay()
+
+/**
+ * The two lists this page keeps, and why they are two.
+ *
+ * A session either has a place in the queue or it does not, and the API
+ * says which by sending `priority` present-and-null. The difference is not
+ * cosmetic: the queued ones can be reordered and are shown in the order a
+ * worker will reach them, and the rest cannot be reordered at all and are
+ * shown in the order that ranks what a person still has to do. One list
+ * carrying both would have to be in one of those orders, which makes the
+ * other one wrong — and it would offer a handle on rows that have nothing
+ * to move.
+ */
+const queued = computed(() => claimOrderSessions(queue.value?.sessions ?? []))
+const waiting = computed(() => unqueuedSessions(queue.value?.sessions ?? []))
+
+/** Which page of the not-queued list is on screen. That list is not
+ * reordered, so its window is an ordinary pager with nothing following a
+ * grab around. */
+const waitingPage = ref(1)
+const waitingShown = computed(() => pageSlice(waiting.value, waitingPage.value))
+const waitingOffset = computed(() => (waitingPage.value - 1) * QUEUE_PAGE_SIZE)
+const waitingSummary = computed(() =>
+ queueSliceSummary(waiting.value.length, waitingOffset.value, waitingShown.value.length),
+)
+
+// A guild switched is a different list entirely, so it starts at the top.
+watch(selected, () => {
+ waitingPage.value = 1
+})
+
+// The list also shrinks on its own, as sessions finish. Clamped rather than
+// reset, because a reader on page three whose section lost one row has not
+// asked to be sent back to the beginning — but a page that no longer exists
+// is an empty section under a pager still offering it.
+watch(
+ () => waiting.value.length,
+ (count) => {
+ waitingPage.value = Math.min(waitingPage.value, pageCount(count, QUEUE_PAGE_SIZE))
+ },
+)
+
+/**
+ * The queue renumbered by an order the server has just committed to.
+ *
+ * Applied here rather than inside the panel because the answer is about
+ * the *page's* copy of the queue: every reorder changes numbers on rows
+ * nobody touched — that is what "nothing is ever moved forward" means —
+ * and a panel reaching into the page's data to say so would be a second
+ * owner of it.
+ */
+function applyOrder(order: QueueOrder) {
+ const held = queueData.value
+ if (!held?.queue) return
+ queueData.value = {
+ guildId: held.guildId,
+ queue: { ...held.queue, sessions: applyQueueOrder(held.queue.sessions, order) },
+ }
+}
/**
* The reader's clock, and `null` until there is one.
@@ -327,15 +403,6 @@ onMounted(() => {
watch([selected, moving], () => syncWatcher())
onBeforeUnmount(stopWatching)
-
-/** Three tones, three colours. Rendering "a speaker failed for good" and
- * "a worker has it in hand" in the same grey would hide the one
- * distinction this page exists to draw. */
-const TONE_COLOUR: Record = {
- clear: 'var(--color-brand-green)',
- watch: 'var(--color-brand-cyan)',
- alarm: 'var(--color-brand-red)',
-}
@@ -475,7 +542,7 @@ const TONE_COLOUR: Record = {
+ opened with. Above both sections rather than inside one,
+ because the cut is made before either exists. -->
{{ truncation }}
@@ -564,7 +628,7 @@ const TONE_COLOUR: Record = {
and a closed session missing its protocol is counted there
without necessarily fitting in a cut list. -->
-
+
+
+
+
+
-
-
-
{{ queueChannelLabel(item) }}
-
- {{ sessionStartLine(item) }}
-
-
-
- {{ queueSessionState(item).label }}
-
-
-
-
-
- {{ queueSessionState(item).detail }}
+
+ {{ $t('admin.queue.list.unqueuedHeading') }}
+
+
+ {{ $t('admin.queue.list.unqueuedNote') }}
-
-
- {{ queueChannelNote(item) }}
+
+ {{ say(unqueuedSummary(waiting)) }}
-
-
-
{{ count.label }} ·
-
- {{ count.value }}
-
-
-
-
-
-
-
+
- Open this recording
-
-
- Session status
- {{
- item.status || 'unknown'
- }}
- — asking for the transcription again is done there, where what the redo would
- overwrite is on the screen next to the button.
-
+
+
+
+
+
+
+
+ {{ say(waitingSummary) }}
+
-
-
+
+
diff --git a/console/app/utils/queue.ts b/console/app/utils/queue.ts
index 4d5f56b..334627a 100644
--- a/console/app/utils/queue.ts
+++ b/console/app/utils/queue.ts
@@ -39,6 +39,7 @@
*/
import { formatDuration } from '~/utils/duration'
import { formatCount, formatMoment } from '~/utils/format'
+import type { Message } from '~/utils/message'
/* -------------------------------------------------------------------- */
/* What the API describes */
@@ -81,6 +82,19 @@ export interface QueuedSession {
status: string
document_url: string | null
counts: QueueCounts
+ /**
+ * The number this session's outstanding jobs are claimed in, lower
+ * first — and **`null` when it has none**.
+ *
+ * Null is not zero, and the difference is the one this page acts on.
+ * Zero is the ordinary priority and a real place in the queue; null is a
+ * session with nothing to reorder — still recording, or listed only
+ * because one of its jobs died — and a row that must therefore not offer
+ * a way to move it. The API sends the field present-and-null rather than
+ * omitting it, so this console never has to tell "no place in the queue"
+ * from "an API that predates the field".
+ */
+ priority: number | null
}
export interface GuildQueue {
@@ -129,6 +143,22 @@ function asCount(value: unknown): number {
return Math.round(value)
}
+/**
+ * A place in the queue, or `null` for a row that has none.
+ *
+ * Everything that is not a whole number becomes `null`, and that includes
+ * a negative one. The server only ever raises a priority and `0` is what
+ * untouched work carries, so a negative number is not a queue position
+ * this console has ever been able to produce — and reading one as a place
+ * would put a drag handle on a row and then argue with the server about
+ * where it went. `null` says "there is nothing here to move", which is
+ * true of a row whose priority cannot be read for any reason.
+ */
+function asPriority(value: unknown): number | null {
+ if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) return null
+ return Math.round(value)
+}
+
function asCounts(value: unknown): QueueCounts {
const raw = isRecord(value) ? value : {}
return {
@@ -192,6 +222,7 @@ export function parseGuildQueue(payload: unknown): GuildQueue {
status: asText(entry.status) ?? '',
document_url: asText(entry.document_url),
counts: asCounts(entry.counts),
+ priority: asPriority(entry.priority),
},
]
}),
@@ -218,15 +249,23 @@ export function queueStreamPath(guildId: string): string {
/* Naming a session */
/* -------------------------------------------------------------------- */
-/** What to call the channel a session happened in. The whole id when there
- * is no name -- never a shortened one, since snowflakes minted in the
- * same era share their leading digits, and a truncated id is something
- * nobody can search a page for either. */
-export function queueChannelLabel(session: QueuedSession): string {
- const name = session.channel_name?.trim()
- if (name) return `#${name}`
- return session.channel_id ? `Channel ${session.channel_id}` : 'An unnamed channel'
-}
+/**
+ * What to call the channel a session happened in.
+ *
+ * **Not a function any more, and that is the point.** This module used to
+ * answer `Channel 1240377558927872021` and the page set it as a row's
+ * heading in semibold, which is exactly the thing #141 abolished on the
+ * recordings list: eighteen digits in the heading slot read as the
+ * meeting's *name*, nobody has a meeting called that, and a column of them
+ * cannot be scanned. `channelNaming` in `~/utils/recordings` is the one
+ * answer to that question — an absence in the muted role with the id
+ * demoted underneath — and the Queue asks it rather than keeping a second,
+ * older one that no longer agrees with the rest of the console.
+ *
+ * `queueChannelNote` below stays, and is the reason the two pages differ
+ * at all: a recordings row shows an absence, and an administrator looking
+ * at a backlog is additionally owed the explanation for it.
+ */
/**
* The line under a row whose channel has no name, or `null` when it has
@@ -269,6 +308,22 @@ export function sessionStartLine(session: QueuedSession): string {
*/
export type QueueTone = 'clear' | 'watch' | 'alarm'
+/**
+ * Which role token each tone is painted in.
+ *
+ * Here rather than in the page because the page is no longer the only
+ * thing that paints one: a row, the panel that reorders rows, and the
+ * lifecycle band all read the same three tones, and three copies of this
+ * map is how "a speaker failed for good" ends up green in one of them.
+ * The tokens themselves, and whether they can be read against the surface
+ * they sit on, are `main.css` and `palette.spec.ts`.
+ */
+export const QUEUE_TONE_COLOUR: Record = {
+ clear: 'var(--color-brand-green)',
+ watch: 'var(--color-brand-cyan)',
+ alarm: 'var(--color-brand-red)',
+}
+
/**
* What kind of row this is.
*
@@ -542,6 +597,61 @@ export function orderQueueSessions(sessions: readonly QueuedSession[]): QueuedSe
})
}
+/**
+ * Whether this session has a place in the queue at all.
+ *
+ * Read off the API's `priority` and never re-derived from the counts.
+ * They agree today — a session has a priority exactly when it has a
+ * pending or running job — and the moment they stop agreeing, the one
+ * that decides whether a row can be moved has to be the one the endpoint
+ * that moves it also reads. A console that worked it out for itself would
+ * offer a handle on a row the server then refuses to place.
+ */
+export function isQueued(session: QueuedSession): boolean {
+ return session.priority !== null
+}
+
+/**
+ * The sessions that have a place in the queue, in the order a worker will
+ * reach them: `(priority, id)` ascending.
+ *
+ * This is `JobQueue.claim`'s own `ORDER BY priority, id` said in
+ * TypeScript, and it is deliberately **not** `orderQueueSessions`. That
+ * one ranks rows by what a reader can still do about them, which is the
+ * right order for triage and a meaningless one to drag in: a list sorted
+ * by somebody's attention cannot express "this meeting runs before that
+ * one", so a session dropped in it would land somewhere the queue does not
+ * have. The two orders are two questions, and this page now asks both
+ * separately rather than answering one of them with the other.
+ */
+export function claimOrderSessions(sessions: readonly QueuedSession[]): QueuedSession[] {
+ return sessions
+ .filter(isQueued)
+ .sort((a, b) => {
+ const byPriority = (a.priority ?? 0) - (b.priority ?? 0)
+ if (byPriority !== 0) return byPriority
+ // Lower id first, which is what the claim does. Note this points the
+ // opposite way to `orderQueueSessions`' tiebreak, and on purpose:
+ // that one is showing the newest first, this one is showing what
+ // runs first.
+ return compareIds(a.id, b.id)
+ })
+}
+
+/**
+ * The sessions with no place in the queue, in the order they were already
+ * listed in.
+ *
+ * Nothing queued will move any of these on: the meeting is still being
+ * recorded, or every job of it is terminal and one of them died, or it
+ * closed with nothing ever queued. `orderQueueSessions` is exactly the
+ * right order for them — rows needing a person first, newest first inside
+ * that — because attention is all these rows have left to be sorted by.
+ */
+export function unqueuedSessions(sessions: readonly QueuedSession[]): QueuedSession[] {
+ return orderQueueSessions(sessions.filter((session) => !isQueued(session)))
+}
+
/** How many rows nothing will move without a person. The headline figure
* for the list: twelve unfinished sessions where three are stuck says
* something a bare row count does not. */
@@ -549,24 +659,61 @@ export function needsPersonCount(sessions: readonly QueuedSession[]): number {
return sessions.filter((session) => queueAttention(session) === 'needs-person').length
}
-/** The line above the list, naming what it holds and how much of it is
- * actually somebody's problem. */
-export function sessionsSummaryLine(sessions: readonly QueuedSession[]): string {
- const total = sessions.length
- if (total === 0) return 'No unfinished sessions are listed for this server.'
+/**
+ * The line above the queue, naming what is in it.
+ *
+ * It says one thing the old single-list summary could not: that this order
+ * is the order a worker will reach them in, and therefore the thing the
+ * controls beside it change. The stuck rows are no longer counted here
+ * because they are no longer in this list — they have a section of their
+ * own, and a count of them in the wrong section is a count nobody can
+ * reconcile with what they can see.
+ */
+export function queuedSummary(sessions: readonly QueuedSession[]): Message {
+ const count = sessions.length
+ if (count === 0) return { key: 'admin.queue.list.queuedNone' }
+ return { key: 'admin.queue.list.queuedSome', params: { count } }
+}
+
+/**
+ * The line above the sessions with nothing queued, and how many of them
+ * are somebody's problem.
+ *
+ * The two counts are kept apart for the reason the old summary kept them
+ * apart: twelve rows of which three are stuck says something a bare row
+ * count does not. What has changed is that "those are listed first" is no
+ * longer part of it — this whole section is rows nothing will move on its
+ * own, so being first inside it means nothing.
+ */
+export function unqueuedSummary(sessions: readonly QueuedSession[]): Message {
+ const count = sessions.length
+ if (count === 0) return { key: 'admin.queue.list.unqueuedNone' }
const stuck = needsPersonCount(sessions)
- const noun = total === 1 ? 'session' : 'sessions'
- if (stuck === 0) {
- return (
- `${formatCount(total)} unfinished ${noun} here, and none of them is waiting on a person — `
- + 'every one is either being worked on or still being recorded.'
- )
+ if (stuck === 0) return { key: 'admin.queue.list.unqueuedWaiting', params: { count } }
+ return {
+ key: 'admin.queue.list.unqueuedStuck',
+ // `count` governs the verb, so it is the number of sessions that need
+ // somebody; the size of the section rides beside it as a value.
+ params: { count: stuck, total: sessions.length },
+ }
+}
+
+/**
+ * Which slice of a section is on screen, or `null` when the whole of it
+ * is.
+ *
+ * Null rather than "1–3 of 3". A list short enough to fit on one page
+ * needs no arithmetic underneath it, and a sentence that counts three rows
+ * a reader can see all three of reads as a page apologising for itself.
+ */
+export function queueSliceSummary(total: number, offset: number, shown: number): Message | null {
+ if (shown <= 0 || shown >= total) return null
+ return {
+ key: 'admin.queue.list.showing',
+ // Ordinals rather than quantities: these are positions in a list, and
+ // `1,024` is not a position. `i18n/README.md` draws that line.
+ params: { from: String(offset + 1), to: String(offset + shown), total: String(total) },
}
- const verb = stuck === 1 ? 'needs' : 'need'
- return (
- `${formatCount(total)} unfinished ${noun} here; ${formatCount(stuck)} of them ${verb} `
- + 'somebody, because nothing queued will move them on. Those are listed first.'
- )
}
/* -------------------------------------------------------------------- */
diff --git a/console/app/utils/queueOrder.ts b/console/app/utils/queueOrder.ts
new file mode 100644
index 0000000..48ead0b
--- /dev/null
+++ b/console/app/utils/queueOrder.ts
@@ -0,0 +1,345 @@
+/**
+ * What the two reorder endpoints answer, and what a person has to be told
+ * before and after asking for one.
+ *
+ * `POST /api/sessions/{id}/queue/priority` and
+ * `POST /api/guilds/{id}/queue/priority` reply with the same body — a drag
+ * and a quick action are one kind of event, "somebody changed the order
+ * work will be done in", and the console reads them through one parser for
+ * the same reason the API writes them through one serialiser.
+ *
+ * Three properties of that endpoint govern every sentence below, and none
+ * of them are softened. Each one is a way this page could make an
+ * administrator believe something untrue about their own queue:
+ *
+ * - **A reorder only ever holds sessions back.** Priority is one column
+ * shared by every guild in the deployment, so nothing can be moved
+ * forward; "go first" is expressed as everything that was ahead going
+ * second. The visible consequence is that **rows nobody touched show
+ * different numbers afterwards**, and the page must present that as the
+ * move working rather than as something having gone wrong. The invisible
+ * one is that holding a session back holds it back globally, behind other
+ * servers' untouched work as well as this server's, and a quick action
+ * that does it to a whole queue has to say so before it runs.
+ * - **A stale drag is an ordinary outcome, not an error.** Two
+ * administrators reordering at once serialise; the second is answered
+ * `409` with `accepted: false` and **the queue as it now stands**. That
+ * is somebody else having moved something, which deserves a sentence and
+ * a redrawn list — not a dialog, and not the word "failed".
+ * - **`changed` is the only thing that says whether anything happened.**
+ * An order that already held is answered with an empty one, and somebody
+ * who dropped a session back where it came from is owed "nothing to do"
+ * rather than "done".
+ *
+ * Every sentence is a {@link Message} keyed under `admin.queue.*`; see
+ * `i18n/README.md` for why a module returns a key and never words.
+ */
+import type { Message } from './message'
+import type { QueuedSession } from './queue'
+
+/* -------------------------------------------------------------------- */
+/* What the endpoints answer */
+/* -------------------------------------------------------------------- */
+
+/** One session's place in the queue. `priority` is never null here, unlike
+ * the field of the same name on a queue listing: everything in this list
+ * has outstanding work by construction, which is what having a place
+ * means. */
+export interface QueuePosition {
+ sessionId: string
+ priority: number
+}
+
+export interface QueueOrder {
+ /** False when nothing was written. The only reason that happens is a
+ * queue that moved under the request. */
+ accepted: boolean
+ /** The API's own words for a refusal. Never displayed — this console
+ * writes its own sentences — but parsed so that a refusal without one
+ * is distinguishable from a refusal this console failed to read. */
+ refusal: string | null
+ /** The sessions this request actually moved. Empty is a real answer and
+ * means "the queue was already in this order". */
+ changed: string[]
+ /** The whole queue, in the order a worker will now reach it. */
+ order: QueuePosition[]
+}
+
+function isRecord(value: unknown): value is Record {
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
+}
+
+function asId(value: unknown): string | null {
+ if (typeof value !== 'string') return null
+ const text = value.trim()
+ return text === '' ? null : text
+}
+
+/**
+ * The order in a payload, or `null` when the payload is not one.
+ *
+ * Null is doing real work here and is not the defensive habit
+ * `parseGuildQueue` deliberately avoids. Both a success and a stale drag
+ * answer with this shape — the difference is `accepted`, not the body — so
+ * the console asks for the body of every reply and decides from the shape
+ * whether it was answered or refused for some other reason entirely. A
+ * `404` carries `{"error": "no such session"}`, which has no order in it,
+ * and reading that as an empty queue would redraw the page as though every
+ * session had left it.
+ */
+export function parseQueueOrder(payload: unknown): QueueOrder | null {
+ if (!isRecord(payload)) return null
+ if (typeof payload.accepted !== 'boolean') return null
+ if (!Array.isArray(payload.order)) return null
+ const changed = Array.isArray(payload.changed) ? payload.changed : []
+ return {
+ accepted: payload.accepted,
+ refusal: typeof payload.refusal === 'string' ? payload.refusal : null,
+ changed: changed.flatMap((entry) => {
+ const id = asId(entry)
+ return id ? [id] : []
+ }),
+ order: payload.order.flatMap((entry) => {
+ if (!isRecord(entry)) return []
+ const id = asId(entry.session_id)
+ const priority = entry.priority
+ if (!id || typeof priority !== 'number' || !Number.isFinite(priority)) return []
+ return [{ sessionId: id, priority: Math.round(priority) }]
+ }),
+ }
+}
+
+/** Where one session is placed relative to its neighbours. The id is
+ * escaped for the reason `queuePath` escapes its own: a string allowed to
+ * contain a slash is a string allowed to address a different endpoint. */
+export function sessionPriorityPath(sessionId: string): string {
+ return `/sessions/${encodeURIComponent(sessionId)}/queue/priority`
+}
+
+/** Where a whole guild's queue is reordered by a rule. */
+export function guildPriorityPath(guildId: string): string {
+ return `/guilds/${encodeURIComponent(guildId)}/queue/priority`
+}
+
+/**
+ * The rows the page is showing, renumbered by the order that came back.
+ *
+ * Applied rather than waited for. The answer carries the whole queue —
+ * including, necessarily, the numbers of sessions nobody touched, because
+ * that is how "go first" is expressed — so the list can settle into the
+ * order the server just committed to without a round trip, and without the
+ * moved row appearing to snap back while a re-read is in flight.
+ *
+ * **A session missing from the answer has left the queue**, not kept its
+ * old place: the order is the whole of it, so an id that is not in it has
+ * no outstanding work any more. Its priority becomes null, which is what
+ * takes its handle away, and the row itself stays — a re-read decides
+ * whether it is still worth listing at all, and that is not this function's
+ * question.
+ */
+export function applyQueueOrder(
+ sessions: readonly QueuedSession[],
+ order: QueueOrder,
+): QueuedSession[] {
+ const places = new Map(order.order.map((position) => [position.sessionId, position.priority]))
+ return sessions.map((session) => {
+ const priority = places.get(session.id)
+ if (priority === undefined) {
+ return session.priority === null ? session : { ...session, priority: null }
+ }
+ return session.priority === priority ? session : { ...session, priority }
+ })
+}
+
+/* -------------------------------------------------------------------- */
+/* What to say once it has landed */
+/* -------------------------------------------------------------------- */
+
+/** Whether a sentence about a reorder is news, a refusal, or neither. Used
+ * for the colour of the line and nothing else — the words say the same
+ * thing without it. */
+export type ReorderTone = 'clear' | 'watch' | 'alarm'
+
+export interface ReorderReport {
+ message: Message
+ tone: ReorderTone
+ /** Whether the page should redraw from `order`. True even for a refusal:
+ * a 409's body is the queue as it now stands, which is exactly what a
+ * page that has just been told its picture is stale needs. */
+ redraw: boolean
+}
+
+/**
+ * What happened, in one sentence.
+ *
+ * The refusal is deliberately not worded as a failure. Nothing broke and
+ * nobody did anything wrong: somebody else moved a session, or one
+ * finished, between the list being drawn and the drop being sent. The
+ * sentence therefore says what happened, that nothing was written, and
+ * that the list underneath is now the real order — which is the only thing
+ * that makes trying again a sensible act rather than a guess.
+ *
+ * `changed` separates "done" from "nothing to do". An administrator who
+ * dropped a session back where it started, or pressed a quick action twice,
+ * gets the second — because the alternative is a page that says "done"
+ * about a request that did nothing, which is how somebody comes to believe
+ * a reorder is in place that is not.
+ */
+export function reorderReport(order: QueueOrder): ReorderReport {
+ if (!order.accepted) {
+ return {
+ message: { key: 'admin.queue.order.stale' },
+ tone: 'watch',
+ redraw: true,
+ }
+ }
+ if (order.changed.length === 0) {
+ return {
+ message: { key: 'admin.queue.order.unchanged' },
+ tone: 'clear',
+ redraw: true,
+ }
+ }
+ return {
+ message: { key: 'admin.queue.order.moved', params: { count: order.changed.length } },
+ tone: 'clear',
+ redraw: true,
+ }
+}
+
+/** `ApiError` names it `status`; a raw `$fetch` failure may name it
+ * `statusCode`; a request that never got a response has neither. The same
+ * reading `describeQueueError` does, restated here rather than shared
+ * because that one answers about a *read* and this one about a write, and
+ * the two sets of sentences are not interchangeable. */
+function statusOf(error: unknown): number | null {
+ if (!isRecord(error)) return null
+ for (const candidate of [error.status, error.statusCode]) {
+ if (typeof candidate === 'number' && Number.isFinite(candidate)) {
+ return candidate === 0 ? null : candidate
+ }
+ }
+ return null
+}
+
+/**
+ * A reorder the API would not do, in a sentence somebody can act on.
+ *
+ * Built from the status alone, because that is all `ApiError` carries —
+ * the API's own `{"error": …}` never reaches this console by design. The
+ * 409 is not here on purpose: it is not a failure and never travels as
+ * one, it is parsed as an order and reported by {@link reorderReport}.
+ */
+export function reorderFailure(error: unknown): Message {
+ switch (statusOf(error)) {
+ case 401:
+ return { key: 'admin.queue.order.failedSignedOut' }
+ case 403:
+ case 404:
+ // The API answers 404 both for a session that is gone and for a
+ // guild the caller does not administer, and refuses to tell them
+ // apart. One sentence has to cover both without guessing.
+ return { key: 'admin.queue.order.failedGone' }
+ case 400:
+ // Nothing a person can type reaches this endpoint: the placement is
+ // built from a list the page is holding, and the rule is one of two
+ // literals. A 400 is therefore this console's bug, and saying so is
+ // more use than a sentence that implies the reader mis-clicked.
+ return { key: 'admin.queue.order.failedRefused' }
+ case null:
+ return { key: 'admin.queue.order.failedUnreachable' }
+ default:
+ return { key: 'admin.queue.order.failedUnknown' }
+ }
+}
+
+/* -------------------------------------------------------------------- */
+/* The two quick actions */
+/* -------------------------------------------------------------------- */
+
+/** A rule the guild-wide endpoint knows. Literals of the API's own
+ * registry: an unknown one is a 400 rather than a fallback, so this list
+ * and `sturnus.application.priorities.KNOWN_RULES` have to agree. */
+export type QueueRuleName = 'many-participants-first' | 'short-recordings-first'
+
+export interface QueueRule {
+ rule: QueueRuleName
+ /** The button. */
+ nameKey: string
+ /** What it ranks by, beside the button, before anything is pressed. */
+ blurbKey: string
+}
+
+/**
+ * The quick actions, in the order they are offered.
+ *
+ * Biggest-meeting-first leads because it is the one that answers the
+ * question people actually arrive with — eight people are waiting on one
+ * document and one person is waiting on another.
+ */
+export const QUEUE_RULES: readonly QueueRule[] = [
+ {
+ rule: 'many-participants-first',
+ nameKey: 'admin.queue.rules.participants.name',
+ blurbKey: 'admin.queue.rules.participants.blurb',
+ },
+ {
+ rule: 'short-recordings-first',
+ nameKey: 'admin.queue.rules.short.name',
+ blurbKey: 'admin.queue.rules.short.blurb',
+ },
+]
+
+export function findQueueRule(rule: string): QueueRule | null {
+ return QUEUE_RULES.find((known) => known.rule === rule) ?? null
+}
+
+export interface RuleConfirmation {
+ title: Message
+ /** Said as separate sentences and kept separate. A paragraph carrying
+ * all of them is skimmed exactly where the reader most needs to notice
+ * that this reaches sessions which are not on the screen. */
+ consequences: Message[]
+ confirm: Message
+}
+
+/**
+ * What a quick action will do, said before it does it.
+ *
+ * Pressing one of these is not like pressing a button on a row. It
+ * rewrites the priority of **every** outstanding session in the server,
+ * including the ones the list was cut short of showing, and it cannot be
+ * undone by pressing it again — the numbers it wrote are the numbers that
+ * stay, and the opposite rule would hold yet more work back rather than
+ * putting anything back where it was. None of that is visible from the
+ * button, so it is said in a panel that opens where the button is, in the
+ * shape `ConsentCard` established for a decision somebody may not be able
+ * to see the whole of.
+ *
+ * The unmeasured-audio caveat is part of the shortest-first confirmation
+ * rather than a note somewhere on the page, because it is the difference
+ * between that rule being useful and being a no-op: a session nothing has
+ * ever transcribed has no measured length, ranks after every measured one,
+ * and keeps the place it had. On a queue of fresh recordings the rule
+ * therefore does very little, and somebody choosing it deserves to learn
+ * that where they are choosing it rather than afterwards.
+ */
+export function ruleConfirmation(rule: QueueRuleName): RuleConfirmation {
+ const shared: Message[] = [
+ { key: 'admin.queue.rules.wholeServer' },
+ { key: 'admin.queue.rules.neverForward' },
+ { key: 'admin.queue.rules.notUndone' },
+ ]
+ if (rule === 'short-recordings-first') {
+ return {
+ title: { key: 'admin.queue.rules.short.confirmTitle' },
+ consequences: [{ key: 'admin.queue.rules.short.unmeasured' }, ...shared],
+ confirm: { key: 'admin.queue.rules.short.confirm' },
+ }
+ }
+ return {
+ title: { key: 'admin.queue.rules.participants.confirmTitle' },
+ consequences: [{ key: 'admin.queue.rules.participants.ties' }, ...shared],
+ confirm: { key: 'admin.queue.rules.participants.confirm' },
+ }
+}
diff --git a/console/app/utils/queueReorder.ts b/console/app/utils/queueReorder.ts
new file mode 100644
index 0000000..90dc72c
--- /dev/null
+++ b/console/app/utils/queueReorder.ts
@@ -0,0 +1,240 @@
+/**
+ * Moving one session to a different place in the queue — the arithmetic,
+ * with no pointer and no element anywhere near it.
+ *
+ * A drag is the one interaction in this console that has no meaning at all
+ * until something has been decided about position, and every one of those
+ * decisions is a way a queue can be reordered into something nobody asked
+ * for. So they live here, where they are ordinary functions over lists of
+ * ids, and the page is left with the two things that genuinely need a
+ * browser: which element the pointer is over, and where focus goes.
+ *
+ * **A move is expressed relative to a neighbour, never as an index.** The
+ * API refuses an index and it is right to: an index is an absolute claim
+ * about a list the browser was showing a moment ago, and two
+ * administrators dragging at once would each be numbering a different
+ * list. `placementFor` therefore ends at `first`, `last`, or the id of the
+ * session to sit beside — a sentence that still means something after
+ * somebody else's move has landed.
+ *
+ * **A row is picked up, moved, and put down.** The alternative — one
+ * request per arrow press — sends four writes to move a session four
+ * places, and the three in the middle are orders nobody wanted the queue
+ * to be in even briefly. So a {@link Grab} is a held position: the page
+ * previews it, and one request is sent when it is dropped. A mouse drag
+ * and a keyboard move produce the same `Grab` and therefore the same
+ * request, which is what keeps the keyboard path from being a second,
+ * lesser implementation that drifts.
+ *
+ * **Nothing moved is not the same as something moved back.** A grab whose
+ * held position equals where it started yields no placement at all, so a
+ * session dragged two pixels and released costs no request — rather than a
+ * write the server would answer with `changed: []` and the page would have
+ * to word as "nothing to do" after the fact.
+ *
+ * Every sentence here is a {@link Message}, keyed under `admin.queue.*`;
+ * see `i18n/README.md` for why a module returns a key and never words.
+ */
+import type { Message } from './message'
+
+/* -------------------------------------------------------------------- */
+/* Where a session ends up */
+/* -------------------------------------------------------------------- */
+
+/** The four things a drop can mean, and exactly the four the API takes. */
+export type PlacementWhere = 'first' | 'last' | 'before' | 'after'
+
+/**
+ * A placement, in the shape `POST /sessions/{id}/queue/priority` reads.
+ *
+ * `session` is the neighbour to sit beside. It is required by `before` and
+ * `after` and **refused** by `first` and `last` rather than ignored, so the
+ * field is absent from those two rather than null: the endpoint treats a
+ * placement that names both an end and a neighbour as somebody who
+ * believes they said where, and refuses the whole request.
+ */
+export interface Placement {
+ place: PlacementWhere
+ session?: string
+}
+
+/**
+ * A session picked up, and where it is currently being held.
+ *
+ * `from` and `to` are indexes into the queue's own order — the whole
+ * queue, not the page of it on screen. Paging is a window, and a window
+ * must not be able to change what a move means.
+ */
+export interface Grab {
+ id: string
+ from: number
+ to: number
+}
+
+function clamp(value: number, last: number): number {
+ return Math.min(Math.max(0, Math.floor(value)), Math.max(0, last))
+}
+
+/**
+ * Picks a session up, or `null` when it is not in this queue.
+ *
+ * Null rather than a grab at index zero. A session that is not in the list
+ * cannot be moved within it, and a grab that quietly meant "the first row"
+ * would move whichever row happened to be first — which is the one failure
+ * a reorder must not have, because it looks exactly like the feature
+ * working.
+ */
+export function grabSession(ids: readonly string[], id: string): Grab | null {
+ const at = ids.indexOf(id)
+ if (at < 0) return null
+ return { id, from: at, to: at }
+}
+
+/**
+ * The same session held one place further up or down, refusing to leave
+ * the list.
+ *
+ * Clamped rather than wrapped. A row at the top that answers ArrowUp by
+ * jumping to the bottom has moved the session to the far end of the queue
+ * on a keystroke somebody pressed to check they were already at the top.
+ */
+export function moveGrabBy(grab: Grab, total: number, delta: number): Grab {
+ return { ...grab, to: clamp(grab.to + delta, total - 1) }
+}
+
+/** The same session held at a named position — a mouse drop, or the ends
+ * the Home and End keys ask for. */
+export function moveGrabTo(grab: Grab, total: number, at: number): Grab {
+ return { ...grab, to: clamp(at, total - 1) }
+}
+
+/**
+ * The order this queue would be in if the grab were dropped now.
+ *
+ * What the page renders while a row is being held. It is a preview and
+ * never a claim: nothing has been written until the drop is answered, and
+ * the answer is the order the page then draws.
+ */
+export function grabbedOrder(ids: readonly string[], grab: Grab): string[] {
+ const rest = ids.filter((id) => id !== grab.id)
+ const at = clamp(grab.to, rest.length)
+ return [...rest.slice(0, at), grab.id, ...rest.slice(at)]
+}
+
+/**
+ * What to ask the server for, or `null` when the grab landed where it
+ * started.
+ *
+ * The neighbour is read off the list **with the grabbed session taken
+ * out**, which is the only reading that survives a move in either
+ * direction: with it left in, "after the row above me" means one thing
+ * moving up and a different thing moving down, and the off-by-one is
+ * invisible until somebody drags a session downwards.
+ *
+ * The two ends are named as ends rather than as "after the last one",
+ * because they are the two placements that stay true when the queue
+ * changes underneath them. A session dropped at the front is meant to run
+ * first, and it should still run first if two more sessions arrive between
+ * the drop and the write.
+ */
+export function placementFor(ids: readonly string[], grab: Grab): Placement | null {
+ if (!ids.includes(grab.id)) return null
+ if (grab.to === grab.from) return null
+ const rest = ids.filter((id) => id !== grab.id)
+ const at = clamp(grab.to, rest.length)
+ if (at === 0) return { place: 'first' }
+ if (at >= rest.length) return { place: 'last' }
+ const neighbour = rest[at - 1]
+ if (neighbour === undefined) return null
+ return { place: 'after', session: neighbour }
+}
+
+/* -------------------------------------------------------------------- */
+/* The window the queue is read through */
+/* -------------------------------------------------------------------- */
+
+/**
+ * How many sessions of the queue are shown at once.
+ *
+ * Five, and not the twenty `~/utils/paging` uses for recordings. A row
+ * there is a line in a list; a row here is a heading, a sentence saying
+ * what will happen next without anybody doing anything, four counts and a
+ * link — so twenty of them is several screens, which is the complaint this
+ * pager exists to answer. Twenty would also be a pager that never appears:
+ * the API cuts the list at twenty sessions, so a page of twenty is always
+ * page one of one.
+ */
+export const QUEUE_PAGE_SIZE = 5
+
+/** Which page a position in the whole queue falls on, counting from one. */
+export function pageOfIndex(index: number, size: number = QUEUE_PAGE_SIZE): number {
+ if (size <= 0) return 1
+ return Math.floor(Math.max(0, index) / size) + 1
+}
+
+/**
+ * The page the reader should be looking at while a row is held.
+ *
+ * The window follows the grab rather than the grab being confined to the
+ * window. Otherwise a keyboard move stops dead at the fifth row for a
+ * reason that is about pagination and nothing to do with the queue, and
+ * "move this to the front" — the move somebody most often wants — would be
+ * the one move the keyboard could not make.
+ */
+export function pageForGrab(grab: Grab | null, page: number, size: number = QUEUE_PAGE_SIZE): number {
+ if (!grab) return page
+ return pageOfIndex(grab.to, size)
+}
+
+/** The slice of the queue on one page. */
+export function pageSlice(rows: readonly T[], page: number, size: number = QUEUE_PAGE_SIZE): T[] {
+ if (size <= 0) return [...rows]
+ const from = Math.max(0, (Math.max(1, Math.floor(page)) - 1) * size)
+ return rows.slice(from, from + size)
+}
+
+/* -------------------------------------------------------------------- */
+/* What a move sounds like */
+/* -------------------------------------------------------------------- */
+
+/**
+ * What the keyboard does here, said on the control rather than left to be
+ * discovered.
+ *
+ * A reorder that is only reachable by dragging is not a control at all for
+ * anybody who does not drag, and one that is reachable by arrow keys
+ * nobody mentions is barely better. The handle carries this sentence as
+ * its description, so it is read out when the handle is focused and is on
+ * screen for everybody else.
+ */
+export const REORDER_INSTRUCTIONS: Message = { key: 'admin.queue.order.instructions' }
+
+/**
+ * Where a held row is now, for the live region that says so after every
+ * keystroke.
+ *
+ * Both numbers are strings rather than quantities: a position in a queue
+ * is an ordinal, and `1,024` is not a place — the same line `i18n/README.md`
+ * draws for page numbers and years.
+ */
+export function heldMessage(grab: Grab, total: number): Message {
+ return {
+ key: 'admin.queue.order.held',
+ params: { position: String(grab.to + 1), total: String(total) },
+ }
+}
+
+/** What was picked up, said once when it is picked up. */
+export function pickedUpMessage(label: string, grab: Grab, total: number): Message {
+ return {
+ key: 'admin.queue.order.pickedUp',
+ params: { session: label, position: String(grab.to + 1), total: String(total) },
+ }
+}
+
+/** A grab abandoned. Says the row went back, because the page has been
+ * showing it somewhere else and the reader needs to know that undid
+ * itself rather than half-applied. */
+export function droppedBackMessage(label: string): Message {
+ return { key: 'admin.queue.order.droppedBack', params: { session: label } }
+}
diff --git a/console/app/utils/recordings.ts b/console/app/utils/recordings.ts
index 9bf0b32..0da3fb2 100644
--- a/console/app/utils/recordings.ts
+++ b/console/app/utils/recordings.ts
@@ -218,7 +218,26 @@ export interface ChannelNaming {
id: string | null
}
-export function channelNaming(session: RecordedSession): ChannelNaming {
+/**
+ * The two fields naming a channel actually reads.
+ *
+ * Written out so that the queue page can ask the same question of its own
+ * rows without either owning a second copy of the answer. "An unresolved
+ * channel reads as an absence, not as a name" is one decision about how
+ * this console presents a snowflake, and a second implementation of it is
+ * a second answer — the one that goes on saying `Channel 1240377…` in
+ * semibold long after everybody agreed it should not.
+ *
+ * That is also why the sentence stays keyed under `recordings.*` when the
+ * Queue borrows it: one absence, one translation. `i18n/README.md` records
+ * the exception.
+ */
+export interface ChannelNamed {
+ channel_id: string
+ channel_name: string | null
+}
+
+export function channelNaming(session: ChannelNamed): ChannelNaming {
const name = session.channel_name?.trim()
if (name) return { named: true, heading: `#${name}`, id: null }
return { named: false, heading: { key: 'recordings.channelUnnamed' }, id: session.channel_id }
diff --git a/console/i18n/README.md b/console/i18n/README.md
index 8f66390..77db10e 100644
--- a/console/i18n/README.md
+++ b/console/i18n/README.md
@@ -51,11 +51,11 @@ matches the file that renders it:
| `admin.reporting.*`| `pages/admin/reporting.vue` |
| `ui.*` | `components/ui/*` — the shared controls, and the gallery |
-`admin.settings.*`, `admin.consents.*` and `admin.queue.*` do not exist yet:
-Bot Settings, User Settings and the Queue are still English, and are being
-rewritten in three other pull requests. They are listed so that whichever of
-those lands first does not have to invent a name, and so that two of them
-landing in parallel do not invent two.
+`admin.settings.*` and `admin.consents.*` do not exist in full yet: Bot
+Settings and User Settings are still English, and are being rewritten in
+other pull requests. They are listed so that whichever of those lands first
+does not have to invent a name, and so that two of them landing in parallel
+do not invent two.
Until then `utils/queue.ts` and `utils/consents.ts` still build English
sentences by hand, and three helpers exist only to serve them:
@@ -85,6 +85,23 @@ name (`common.formatOutline` and its two neighbours) lives in `common.*`,
because the recording page has to say the same three words beside a
published document and two copies of a word are two words that drift.
+`admin.queue.*` is half-populated on the same terms. The Queue page's older
+prose — its four lifecycle notes, its three caveats, its per-row state
+sentences — is still hard-coded English in `utils/queue.ts` and moves when
+the sweep reaches it. What is keyed is everything the reordering work added:
+the two sections the list was split into, the move handle and what it
+announces, and the two quick actions with the consequences they state before
+they run. New strings on that page go through `$t` from now on.
+
+One sentence on that page is deliberately **not** in its own namespace.
+`recordings.channelUnnamed` — the "Unnamed channel" a row shows where the
+channel's name has gone — is rendered by the Queue as well, because
+`channelNaming` in `utils/recordings.ts` is the console's single answer to
+"how is an unresolved snowflake presented", and a second copy of the string
+under `admin.queue.*` would be a second translation of one absence, free to
+drift from the first. The namespace rule holds for sentences a page owns;
+this is a sentence a *decision* owns.
+
`ui.*` is the one namespace that does not name a page. It serves
`app/components/ui/*` — the six shared controls every page is about to be
built out of — and the gallery at `/dev/ui` that renders them. They are keyed
diff --git a/console/i18n/locales/de.json b/console/i18n/locales/de.json
index e63c72c..8c5c15e 100644
--- a/console/i18n/locales/de.json
+++ b/console/i18n/locales/de.json
@@ -559,6 +559,62 @@
"outcomeRefused": "Abgelehnt, ohne dass Sturnus einen Grund genannt hat. Bei dieser Person hat sich nichts geändert."
}
},
+ "queue": {
+ "list": {
+ "queuedHeading": "Die Reihenfolge, in der dieser Server abgearbeitet wird",
+ "queuedNote": "Jede Sitzung, für die noch ein Auftrag wartet oder läuft, von vorn nach hinten. Genau in dieser Reihenfolge greift ein Worker zu, und genau diese Reihenfolge ändern die Bedienelemente hier.",
+ "queuedNone": "Auf diesem Server wartet und läuft nichts, also gibt es keine Reihenfolge festzulegen.",
+ "queuedSome": "Eine Sitzung wartet auf ihre Abarbeitung. | {count} Sitzungen warten auf ihre Abarbeitung, in der gezeigten Reihenfolge.",
+ "unqueuedHeading": "Aufgeführt, aber nicht in der Warteschlange",
+ "unqueuedNote": "Für diese Sitzungen wartet und läuft kein Auftrag, also gibt es nichts umzusortieren und keinen Griff zum Verschieben. Jede steht hier, weil sie gerade aufgezeichnet wird oder weil ohne eine Person nichts weitergeht.",
+ "unqueuedNone": "Für jede unfertige Sitzung dieses Servers ist Arbeit eingeplant.",
+ "unqueuedWaiting": "Eine Sitzung ist ohne eingeplante Arbeit aufgeführt und wartet nicht auf eine Person. | {count} Sitzungen sind ohne eingeplante Arbeit aufgeführt, und keine davon wartet auf eine Person.",
+ "unqueuedStuck": "Eine Sitzung hier braucht jemanden: ohne Zutun geht nichts weiter. | {count} der {total} Sitzungen hier brauchen jemanden: ohne Zutun geht nichts weiter.",
+ "showing": "Angezeigt: {from}–{to} von {total}.",
+ "pagerQueued": "Seiten der Warteschlange",
+ "pagerWaiting": "Seiten der Sitzungen ohne eingeplante Arbeit"
+ },
+ "order": {
+ "handle": "{session} verschieben",
+ "instructions": "Mit Eingabe oder Leertaste eine Sitzung aufnehmen, dann mit Pfeil hoch und runter verschieben, Pos1 nach ganz vorn, Ende nach ganz hinten. Eingabe legt sie dort ab, Escape lässt sie, wo sie war. Mit der Maus ist sie innerhalb der aktuellen Seite ziehbar.",
+ "position": "Läuft als {position} von {total}",
+ "pickedUp": "{session} aufgenommen, Platz {position} von {total}. Mit den Pfeiltasten verschieben, danach mit Eingabe ablegen.",
+ "held": "Platz {position} von {total}.",
+ "droppedBack": "{session} liegt wieder an der alten Stelle, geändert wurde nichts.",
+ "sending": "Sitzung wird verschoben…",
+ "moved": "Verschoben. Eine Sitzung hat ihren Platz gewechselt. | Verschoben. {count} Sitzungen haben den Platz gewechselt — nach vorn geht es nur, indem alles davor nach hinten rückt, deshalb tragen auch unberührte Zeilen jetzt neue Nummern.",
+ "unchanged": "Nichts zu tun: diese Warteschlange stand bereits so.",
+ "stale": "Währenddessen hat jemand anderes in dieser Warteschlange etwas verschoben, deshalb wurde nichts geschrieben. Die Liste unten zeigt die Reihenfolge, wie sie jetzt ist — bei Bedarf erneut verschieben.",
+ "failedSignedOut": "Die Anmeldung ist abgelaufen, verschoben wurde nichts. Erneut anmelden.",
+ "failedGone": "Die Sitzung ist nicht mehr in dieser Warteschlange, oder dieser Server wird von diesem Konto nicht mehr verwaltet — Sturnus antwortet auf beides gleich. Geschrieben wurde nichts; zum aktuellen Stand neu laden.",
+ "failedRefused": "Sturnus hat das als fehlerhaft abgelehnt und nichts geschrieben. Das ist ein Fehler dieser Konsole und nichts, was anders zu bedienen wäre — nur ein Neuladen der Seite hilft.",
+ "failedUnreachable": "Die API war nicht erreichbar, verschoben wurde nichts. Unten steht der zuletzt gelesene Stand.",
+ "failedUnknown": "Sturnus konnte nicht verschieben und nannte keinen Grund. Geschrieben wurde nichts."
+ },
+ "rules": {
+ "heading": "Die ganze Reihenfolge auf einmal ändern",
+ "note": "Zwei Arten, alles Ausstehende dieses Servers neu zu ordnen. Beide schreiben jede Priorität der Warteschlange neu und sagen vorher, was sie tun.",
+ "cancel": "Reihenfolge so lassen",
+ "working": "Wird neu geordnet…",
+ "wholeServer": "Das betrifft jede ausstehende Sitzung dieses Servers, auch die, für die die Liste oben zu kurz war.",
+ "neverForward": "Nichts rückt nach vorn. Dass eine Sitzung zuerst läuft, entsteht dadurch, dass alles davor nach hinten rückt — das hält Arbeit zurück, statt etwas zu beschleunigen, und zwar auch hinter der Arbeit jedes anderen Servers, nicht nur hinter der dieses Servers.",
+ "notUndone": "Die andere Schaltfläche macht das danach nicht rückgängig, sondern schreibt eine eigene Reihenfolge über die Nummern, die diese hier hinterlässt.",
+ "participants": {
+ "name": "Größte Besprechungen zuerst",
+ "blurb": "Ordnet danach, wie viele Personen in einer Sitzung aufgezeichnet wurden.",
+ "confirmTitle": "Größte Besprechungen zuerst laufen lassen?",
+ "ties": "Sitzungen mit gleich vielen Teilnehmenden behalten die Reihenfolge, die sie schon hatten.",
+ "confirm": "Nach Teilnehmenden ordnen"
+ },
+ "short": {
+ "name": "Kürzeste Aufnahmen zuerst",
+ "blurb": "Ordnet nach gemessener Audiolänge, und was nie gemessen wurde, kommt zuletzt.",
+ "confirmTitle": "Kürzeste Aufnahmen zuerst laufen lassen?",
+ "unmeasured": "Eine Aufnahme, die nie gemessen wurde, hat keine Länge zum Ordnen; sie kommt hinter jede gemessene und behält ihren bisherigen Platz. Die meisten Sitzungen sind ungemessen, bis sie einmal transkribiert wurden — bei lauter frischen Aufnahmen ändert das also kaum etwas, bei erneut eingereihten dagegen schon.",
+ "confirm": "Nach Länge ordnen"
+ }
+ }
+ },
"settings": {
"kindVoice": "Sprachkanäle",
"kindStage": "Bühnenkanäle",
diff --git a/console/i18n/locales/en.json b/console/i18n/locales/en.json
index 1126f7f..be9297a 100644
--- a/console/i18n/locales/en.json
+++ b/console/i18n/locales/en.json
@@ -559,6 +559,62 @@
"outcomeRefused": "Refused, and Sturnus did not say why. Nothing of theirs was changed."
}
},
+ "queue": {
+ "list": {
+ "queuedHeading": "The order this server’s work will run in",
+ "queuedNote": "Every session with a job still queued or running, first to last. This is the order a worker reaches them in, and it is the order the controls here change.",
+ "queuedNone": "Nothing is queued or running in this server, so there is no order to set.",
+ "queuedSome": "One session is waiting to run. | {count} sessions are waiting to run, in the order shown.",
+ "unqueuedHeading": "Listed, but not in the queue",
+ "unqueuedNote": "These sessions have no job queued or running, so there is nothing about them to reorder and no handle to move them by. Each one is here because it is being recorded at this moment, or because nothing will move it on without a person.",
+ "unqueuedNone": "Every unfinished session in this server has work queued for it.",
+ "unqueuedWaiting": "One session is listed with nothing queued, and it is not waiting on a person. | {count} sessions are listed with nothing queued, and none of them is waiting on a person.",
+ "unqueuedStuck": "One session here needs somebody: nothing queued will move it on. | {count} of the {total} sessions here need somebody: nothing queued will move them on.",
+ "showing": "Showing {from}–{to} of {total}.",
+ "pagerQueued": "Pages of the queue",
+ "pagerWaiting": "Pages of the sessions with nothing queued"
+ },
+ "order": {
+ "handle": "Move {session}",
+ "instructions": "Press Enter or Space to pick a session up, then Up and Down to move it, Home to send it to the front and End to the back. Enter puts it down where it is; Escape leaves it where it was. It can also be dragged with a mouse, within the page it is on.",
+ "position": "Runs {position} of {total}",
+ "pickedUp": "{session} picked up, position {position} of {total}. Move it with the arrow keys, then press Enter to put it down.",
+ "held": "Position {position} of {total}.",
+ "droppedBack": "{session} was put back where it was, and nothing was changed.",
+ "sending": "Moving this session…",
+ "moved": "Moved. One session’s place in this queue changed. | Moved. {count} sessions changed place — going first is expressed as everything that was ahead going second, so rows nobody touched carry new numbers too.",
+ "unchanged": "Nothing to do: this queue was already in that order.",
+ "stale": "Somebody else moved something in this queue while that was in the air, so nothing was written. The list below is the order as it now stands — move it again if it still needs moving.",
+ "failedSignedOut": "Your session has ended, so nothing was moved. Sign in again.",
+ "failedGone": "The session has left this queue, or you no longer administer this server — Sturnus answers the same way to both. Nothing was written; refresh to see where things stand.",
+ "failedRefused": "Sturnus refused that as malformed and wrote nothing. That is a fault in this console rather than anything to do differently, so reloading the page is the only thing worth trying.",
+ "failedUnreachable": "Could not reach the API, so nothing was moved. The order below is the last one read.",
+ "failedUnknown": "Sturnus could not move it and said nothing about why. Nothing was written."
+ },
+ "rules": {
+ "heading": "Change the whole order at once",
+ "note": "Two ways to re-rank everything this server has outstanding. Each rewrites every priority in the queue, so each says what it will do before it does it.",
+ "cancel": "Leave the order alone",
+ "working": "Reordering…",
+ "wholeServer": "This reaches every session this server has outstanding, including any the list above was cut short of showing.",
+ "neverForward": "Nothing is moved forward. A session going first is expressed as everything that was ahead of it going second, so this holds work back rather than speeding anything up — and it holds it back behind every other server’s work as well, not only behind this server’s.",
+ "notUndone": "Pressing the other button afterwards does not undo this. It writes an order of its own on top of the numbers this one leaves behind.",
+ "participants": {
+ "name": "Biggest meetings first",
+ "blurb": "Ranks by how many people were recorded in each session.",
+ "confirmTitle": "Run the biggest meetings first?",
+ "ties": "Sessions with the same number of participants keep the order they already had.",
+ "confirm": "Reorder by participants"
+ },
+ "short": {
+ "name": "Shortest recordings first",
+ "blurb": "Ranks by how much audio has been measured, and a session nothing has measured ranks last.",
+ "confirmTitle": "Run the shortest recordings first?",
+ "unmeasured": "A recording nothing has ever measured has no length to rank by, so it goes after every measured one and keeps the place it had. Most sessions are unmeasured until they have been transcribed once, so on a queue of fresh recordings this changes very little — it earns its place on a queue of re-queued ones.",
+ "confirm": "Reorder by length"
+ }
+ }
+ },
"settings": {
"kindVoice": "Voice channels",
"kindStage": "Stage channels",
diff --git a/console/test/adminQueuePage.spec.ts b/console/test/adminQueuePage.spec.ts
index 3f26d5f..b962ee4 100644
--- a/console/test/adminQueuePage.spec.ts
+++ b/console/test/adminQueuePage.spec.ts
@@ -21,12 +21,39 @@
* same reason it is in `requeuePanel.spec.ts`: which of the two ways of
* watching runs is the thing under test.
*/
+import { readFileSync } from 'node:fs'
+import { resolve } from 'node:path'
+
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
-import { Suspense, computed, defineComponent, h, onBeforeUnmount, onMounted, ref, watch } from 'vue'
-
+import {
+ Suspense,
+ computed,
+ defineComponent,
+ h,
+ nextTick,
+ onBeforeUnmount,
+ onMounted,
+ ref,
+ useId,
+ watch,
+} from 'vue'
+import { createI18n, useI18n } from 'vue-i18n'
+
+import QueueOrderPanel from '../app/components/QueueOrderPanel.vue'
+import QueueSessionRow from '../app/components/QueueSessionRow.vue'
+import UiPagination from '../app/components/ui/UiPagination.vue'
+import { useSay } from '../app/composables/useSay'
import QueuePage from '../app/pages/admin/queue.vue'
+/** The real locale files, loaded from disk, for the reason
+ * `uiComponents.spec.ts` loads them: a template asking for
+ * `admin.queue.list.queuedHeadng` renders the key at somebody, and
+ * nothing but a render catches it. */
+function load(locale: string) {
+ return JSON.parse(readFileSync(resolve(process.cwd(), `i18n/locales/${locale}.json`), 'utf8'))
+}
+
/** One guild, so the page renders its name rather than a switcher, and the
* only `