fix: UndoToast now emits expire after 5s so toast self-dismisses

This commit is contained in:
pyr0ball 2026-03-04 11:29:03 -08:00
parent 82eeb4defc
commit 92da5902ba
3 changed files with 60 additions and 5 deletions

View file

@ -71,4 +71,19 @@ describe('UndoToast', () => {
const w = mount(UndoToast, { props: { action: labelAction } })
expect(w.find('[role="status"]').exists()).toBe(true)
})
it('emits expire when tick fires with timestamp beyond DURATION', async () => {
let capturedTick: FrameRequestCallback | null = null
vi.stubGlobal('requestAnimationFrame', (fn: FrameRequestCallback) => {
capturedTick = fn
return 1
})
vi.spyOn(performance, 'now').mockReturnValue(0)
const w = mount(UndoToast, { props: { action: labelAction } })
await import('vue').then(v => v.nextTick())
// Simulate a tick timestamp 6 seconds in — beyond the 5-second DURATION
if (capturedTick) capturedTick(6000)
await import('vue').then(v => v.nextTick())
expect(w.emitted('expire')).toBeTruthy()
})
})

View file

@ -13,7 +13,7 @@ import { ref, onMounted, onUnmounted, computed } from 'vue'
import type { LastAction } from '../stores/label'
const props = defineProps<{ action: LastAction }>()
defineEmits<{ undo: [] }>()
const emit = defineEmits<{ undo: []; expire: [] }>()
const DURATION = 5000
const elapsed = ref(0)
@ -30,14 +30,15 @@ const label = computed(() => {
})
function tick(ts: number) {
if (!start) start = ts
elapsed.value = ts - start
if (elapsed.value < DURATION) {
raf = requestAnimationFrame(tick)
} else {
emit('expire')
}
}
onMounted(() => { raf = requestAnimationFrame(tick) })
onMounted(() => { start = performance.now(); raf = requestAnimationFrame(tick) })
onUnmounted(() => cancelAnimationFrame(raf))
</script>

View file

@ -1,11 +1,19 @@
<template>
<div class="label-view">
<!-- App bar -->
<div class="app-bar">
<span class="app-title">Avocet</span>
<span class="app-subtitle">Email Labeler</span>
</div>
<!-- Header bar -->
<header class="lv-header">
<span class="queue-count">
<template v-if="store.totalRemaining > 0">
<span v-if="loading" class="queue-status">Loading</span>
<template v-else-if="store.totalRemaining > 0">
{{ store.totalRemaining }} remaining
</template>
<span v-else class="queue-status">Queue empty</span>
<span v-if="onRoll" class="badge badge-roll">🔥 On a roll!</span>
<span v-if="speedRound" class="badge badge-speed"> Speed round!</span>
<span v-if="fiftyDeep" class="badge badge-fifty">🎯 Fifty deep!</span>
@ -59,6 +67,7 @@
v-if="store.lastAction"
:action="store.lastAction"
@undo="handleUndo"
@expire="store.clearLastAction()"
/>
</div>
</template>
@ -115,7 +124,10 @@ async function fetchBatch() {
apiError.value = false
const { data, error } = await useApiFetch<{ items: any[]; total: number }>('/api/queue?limit=10')
loading.value = false
if (error || !data) { apiError.value = true; return }
if (error || !data) {
apiError.value = true
return
}
store.queue = data.items
store.totalRemaining = data.total
@ -286,6 +298,33 @@ onUnmounted(() => {
min-height: 100dvh;
}
.app-bar {
display: flex;
align-items: baseline;
gap: 0.5rem;
padding-bottom: 0.25rem;
border-bottom: 2px solid var(--color-border, #d0d7e8);
}
.app-title {
font-family: var(--font-display, var(--font-body, sans-serif));
font-size: 1.25rem;
font-weight: 700;
color: var(--app-primary, #2A6080);
letter-spacing: -0.02em;
}
.app-subtitle {
font-size: 0.75rem;
color: var(--color-text-secondary, #6b7a99);
font-family: var(--font-mono, monospace);
}
.queue-status {
opacity: 0.6;
font-style: italic;
}
.lv-header {
display: flex;
align-items: center;