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

@@ -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;