Kiosk drive filter and wider picker

This commit is contained in:
2026-08-27 10:37:10 -04:00
parent 60c340b07f
commit 4dfbf5f8ec
5 changed files with 190 additions and 13 deletions

View File

@@ -24,6 +24,12 @@ ADR-007 and ADR-002.
### Fixed
- **Muted text on the USB kiosk and in guides was not muted.** Both named a
`--textmuted` variable this app has never defined, so the browser dropped the
declaration and the hints, subtitles and locker lines rendered at full
strength against the text they were meant to sit behind. The variable is
`--text-light`.
- **Plugin reports rendered a raw JSON dump.** The reports page ran a hardcoded
switch over five core report ids and fell through to
`console.error('Unknown report')`, and its results panel was a matching chain
@@ -77,6 +83,13 @@ ADR-007 and ADR-002.
left behind by somebody on leave would then be unreturnable at the kiosk,
which just moves the problem to a person with a login.
The list of free drives is filtered from a search box, and lays itself out in
as many columns as the panel is wide - up to four on a wall display. A cabinet
of thirty drives was one long column somebody had to read through. A drive is
found by its id, by what is written on it, or by where it lives, in whatever
word order comes naturally: "locker engineer" finds the engineering locker,
because that is what people say.
- **Audit log retention.** The audit log is append-only and nothing ever removed
a row, so it grew for as long as a site ran - here to the point where the
audit page was tens of thousands of pages deep and therefore useless, because

View File

@@ -253,7 +253,7 @@ function select (block) {
margin: 0; padding: 0.75rem 1rem; border: 1px dashed var(--border);
border-radius: 4px; font-size: 0.9rem;
}
.gb-imageloading { color: var(--textmuted); }
.gb-imageloading { color: var(--text-light); }
.gb-imagefailed { color: var(--danger, #b3261e); }
.gb-figure figcaption { font-size: 0.8rem; color: var(--text-light); margin-top: 0.35rem; }

View File

@@ -0,0 +1,29 @@
// Narrowing a cabinet of drives from a kiosk keyboard.
//
// Every free drive is already on the client, so this filters what is on screen
// rather than asking the API again - the list is small, and a round trip per
// keystroke on a wall panel is a worse experience than no filter at all.
// What one drive can be found by. 'locker' is in here on purpose: it is how
// people say where a drive lives, so "locker engineer" has to find the drives
// in the engineering locker rather than nothing at all.
export function drivetext (drive) {
return [drive.device_id, drive.device_desc, 'locker', drive.locker_location]
.filter(Boolean).join(' ').toLowerCase()
}
// Every word typed has to appear somewhere in the drive's text, in any order,
// so "engineer kingston" and "kingston engineer" find the same drive. An empty
// query matches everything - a blank box is not a filter.
export function matchesdrive (drive, query) {
const words = String(query || '').trim().toLowerCase().split(/\s+/).filter(Boolean)
if (!words.length) return true
const text = drivetext(drive)
return words.every(word => text.includes(word))
}
export function filterdrives (drives, query) {
const words = String(query || '').trim()
if (!words) return drives
return drives.filter(drive => matchesdrive(drive, query))
}

View File

@@ -0,0 +1,62 @@
// The kiosk filter. Every case here is something somebody standing at the
// cabinet would actually type, including the phrasing they use out loud -
// "locker engineer" rather than the bare locker name.
import { describe, it, expect } from 'vitest'
import { filterdrives, matchesdrive } from './drivefilter'
const DRIVES = [
{ device_id: 'USB-3', device_desc: 'Kingston 16GB', locker_location: 'DT' },
{ device_id: 'USB-4', device_desc: 'SanDisk 32GB', locker_location: 'Engineer' },
{ device_id: 'USB-10', device_desc: 'SanDisk 32GB', locker_location: 'Engineer' },
{ device_id: 'USB-11', device_desc: 'Kingston 64GB', locker_location: 'B11' },
]
describe('filterdrives', () => {
it('an empty box is not a filter', () => {
expect(filterdrives(DRIVES, '')).toHaveLength(4)
expect(filterdrives(DRIVES, ' ')).toHaveLength(4)
})
it('finds a drive by its id', () => {
expect(filterdrives(DRIVES, 'usb-4').map(d => d.device_id)).toEqual(['USB-4'])
})
it('finds drives by what is written on them', () => {
expect(filterdrives(DRIVES, 'kingston').map(d => d.device_id))
.toEqual(['USB-3', 'USB-11'])
})
it('finds a locker by name', () => {
expect(filterdrives(DRIVES, 'engineer').map(d => d.device_id))
.toEqual(['USB-4', 'USB-10'])
})
it('finds a locker the way somebody says it out loud', () => {
expect(filterdrives(DRIVES, 'locker engineer').map(d => d.device_id))
.toEqual(['USB-4', 'USB-10'])
})
it('takes the words in any order', () => {
expect(filterdrives(DRIVES, 'sandisk engineer').map(d => d.device_id))
.toEqual(['USB-4', 'USB-10'])
expect(filterdrives(DRIVES, 'engineer sandisk').map(d => d.device_id))
.toEqual(['USB-4', 'USB-10'])
})
it('ignores case and stray spacing', () => {
expect(filterdrives(DRIVES, ' ENGINEER SanDisk ').map(d => d.device_id))
.toEqual(['USB-4', 'USB-10'])
})
it('matches nothing rather than everything when nothing matches', () => {
expect(filterdrives(DRIVES, 'zzz')).toEqual([])
})
it('does not fall over on a drive missing its description or locker', () => {
const sparse = [{ device_id: 'USB-9' }]
expect(matchesdrive(sparse[0], 'usb-9')).toBe(true)
expect(matchesdrive(sparse[0], 'engineer')).toBe(false)
})
})

View File

@@ -58,12 +58,27 @@
</div>
<div class="drive-group">
<h2>Available</h2>
<div class="drive-group-head">
<h2>Available</h2>
<!-- Typed, not tapped: this panel has a keyboard, and a cabinet of
thirty drives is faster to narrow than to read. -->
<div v-if="session.available.length" class="drive-filter">
<input ref="filterInput" v-model="drivefilter"
class="drive-filter-input"
autocomplete="off" spellcheck="false"
placeholder="Find a drive, a locker..." />
<button v-if="drivefilter" class="drive-filter-clear"
title="Clear" @click="drivefilter = ''">Clear</button>
</div>
</div>
<p v-if="!session.available.length" class="kiosk-hint">
No drives are free right now.
</p>
<p v-else-if="!availabledrives.length" class="kiosk-hint">
No free drive matches "{{ drivefilter }}".
</p>
<div v-else class="drive-list">
<button v-for="drive in session.available" :key="drive.device_id"
<button v-for="drive in availabledrives" :key="drive.device_id"
class="drive-row" :disabled="busy"
@click="act(drive, 'checkout')">
<span class="drive-name">
@@ -117,9 +132,10 @@
</template>
<script setup>
import { ref, onMounted, onBeforeUnmount, nextTick } from 'vue'
import { ref, computed, onMounted, onBeforeUnmount, nextTick } from 'vue'
import api, { settingsApi } from '@/api'
import TouchKeypad from '@/components/TouchKeypad.vue'
import { filterdrives } from '../drivefilter'
const step = ref('badge')
const badge = ref('')
@@ -155,6 +171,20 @@ function focusEntry (event) {
nextTick(() => entryInput.value?.focus())
}
// Narrowing the free drives. The matcher lives in its own module so it can be
// tested without mounting a kiosk.
const drivefilter = ref('')
const filterInput = ref(null)
// Focused as the picker opens, so somebody can simply start typing. The badge
// step owns the keyboard until then.
function focusFilter () {
nextTick(() => filterInput.value?.focus())
}
const availabledrives = computed(
() => filterdrives(session.value.available, drivefilter.value))
// Drive ids sort as TEXT everywhere else, which puts -10 before -3 and reads as
// a jumble on a wall panel. Compare the numeric runs numerically; falls back to
// plain text for ids that carry no digits.
@@ -181,6 +211,7 @@ async function startSession (value) {
badge.value = scanned
manualBadge.value = ''
step.value = 'pick'
focusFilter()
} catch (sessionError) {
error.value = messageFrom(sessionError, 'Could not look that badge up')
} finally {
@@ -247,6 +278,9 @@ function reset () {
error.value = ''
doneMessage.value = ''
manualBadge.value = ''
// The next person at the cabinet must not inherit somebody else's filter and
// conclude there are no drives left.
drivefilter.value = ''
focusEntry()
}
</script>
@@ -273,7 +307,7 @@ function reset () {
}
.kiosk-header {
width: 100%;
max-width: 40rem;
max-width: 72rem;
display: flex;
justify-content: center;
align-items: center;
@@ -297,8 +331,11 @@ function reset () {
max-width: 40rem;
width: 100%;
}
/* The picker holds lists, so its rows stretch - but it keeps the same width as
every other step so the panel does not jump when the step changes.
/* The picker holds lists, so its rows stretch, and it is WIDER than the steps
that hold a single control - a cabinet of thirty drives is three or four
columns of one glance rather than a scroll. The steps that are just a keypad
or a message stay narrow, because stretching one input across a 24 inch panel
reads as a broken page.
It is also height-bounded: the lists absorb whatever space is left over and
scroll inside themselves, so the PAGE never scrolls and the heading saying
whose drives these are stays on screen. A fixed max-height cannot do this -
@@ -306,6 +343,7 @@ function reset () {
present, which is exactly what it did. */
.kiosk-wide {
align-items: stretch;
max-width: 72rem;
max-height: calc(100vh - 9rem);
min-height: 0;
}
@@ -321,7 +359,7 @@ function reset () {
.kiosk-wide .drive-group-yours .drive-list { max-height: 30vh; }
.kiosk-prompt { font-size: 1.5rem; text-align: center; margin: 0; }
.kiosk-greeting { font-size: 1.5rem; margin: 0; text-align: center; }
.kiosk-hint { color: var(--textmuted); text-align: center; margin: 0; }
.kiosk-hint { color: var(--text-light); text-align: center; margin: 0; }
.kiosk-error {
padding: 0.9rem 1.1rem;
border: 1px solid var(--danger, #b3261e);
@@ -335,12 +373,47 @@ function reset () {
.drive-group { width: 100%; }
.drive-group h2 { font-size: 1.2rem; margin: 0 0 0.5rem; }
/* Two columns once there is room, so a cabinet of a dozen drives is one glance
rather than a scroll. The list itself scrolls when it has to - the PAGE must
not, or the person loses the heading telling them whose drives these are. */
/* Heading and filter on one line: the filter belongs to the list under it, and
a row of its own would cost height the lists need. */
.drive-group-head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 1rem;
flex-wrap: wrap;
}
.drive-filter { display: flex; align-items: center; gap: 0.5rem; }
.drive-filter-input {
/* Touch-sized like everything else here, and wide enough for a locker name
rather than just a drive number. */
min-width: 16rem;
padding: 0.6rem 0.8rem;
font-size: 1.1rem;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--bg-card-solid);
color: var(--text);
}
.drive-filter-input:focus {
outline: none;
border-color: var(--primary);
}
.drive-filter-clear {
padding: 0.6rem 0.9rem;
font-size: 1rem;
border: 1px solid var(--border);
border-radius: 6px;
background: transparent;
color: var(--text);
cursor: pointer;
}
/* As many columns as the panel has room for - four on a wall display, one on a
small screen - so a cabinet of a dozen drives is one glance rather than a
scroll. The list itself scrolls when it has to; the PAGE must not, or the
person loses the heading telling them whose drives these are. */
.drive-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(17rem, 1fr));
grid-template-columns: repeat(auto-fill, minmax(16rem, 1fr));
gap: 0.6rem;
flex: 1 1 auto;
min-height: 0;
@@ -364,7 +437,7 @@ function reset () {
}
.drive-row:disabled { opacity: 0.5; }
.drive-name { display: flex; flex-direction: column; gap: 0.15rem; }
.drive-name small { color: var(--textmuted); font-size: 0.85rem; }
.drive-name small { color: var(--text-light); font-size: 0.85rem; }
.drive-action {
flex: 0 0 auto;
padding: 0.5rem 1.1rem;