feat(avocet): LabelView — wires store, API, card stack, keyboard, easter eggs

Implements Task 13: LabelView.vue wires together the label store, API
fetch, card stack, bucket grid, keyboard shortcuts, haptics, motion
preference, and three easter egg badges (on-a-roll, speed round, fifty
deep). App.vue updated to mount LabelView and restore hacker-mode theme
on load. 3 new LabelView tests; all 48 tests pass, build clean.
This commit is contained in:
pyr0ball 2026-03-03 16:21:07 -08:00
parent b623d252d0
commit 382bca28a1
3 changed files with 395 additions and 22 deletions

View file

@ -1,30 +1,39 @@
<script setup lang="ts">
import HelloWorld from './components/HelloWorld.vue'
</script>
<template>
<div>
<a href="https://vite.dev" target="_blank">
<img src="/vite.svg" class="logo" alt="Vite logo" />
</a>
<a href="https://vuejs.org/" target="_blank">
<img src="./assets/vue.svg" class="logo vue" alt="Vue logo" />
</a>
<div id="app" :class="{ 'rich-motion': motion.rich.value }">
<LabelView />
</div>
<HelloWorld msg="Vite + Vue" />
</template>
<style scoped>
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: filter 300ms;
<script setup lang="ts">
import { onMounted } from 'vue'
import { useMotion } from './composables/useMotion'
import { useHackerMode } from './composables/useEasterEgg'
import LabelView from './views/LabelView.vue'
const motion = useMotion()
const { restore } = useHackerMode()
onMounted(() => {
restore() // re-apply hacker mode from localStorage on page load
})
</script>
<style>
/* Global reset + base */
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
body {
font-family: var(--font-body, sans-serif);
background: var(--color-bg, #f0f4fc);
color: var(--color-text, #1a2338);
min-height: 100dvh;
}
.logo.vue:hover {
filter: drop-shadow(0 0 2em #42b883aa);
#app {
min-height: 100dvh;
}
</style>

View file

@ -0,0 +1,46 @@
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import LabelView from './LabelView.vue'
import { describe, it, expect, vi, beforeEach } from 'vitest'
// Mock fetch globally
beforeEach(() => {
setActivePinia(createPinia())
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ items: [], total: 0 }),
text: async () => '',
}))
})
describe('LabelView', () => {
it('shows loading state initially', () => {
const w = mount(LabelView, {
global: { plugins: [createPinia()] },
})
// Should show skeleton while loading
expect(w.find('.skeleton-card').exists()).toBe(true)
})
it('shows empty state when queue is empty after load', async () => {
const w = mount(LabelView, {
global: { plugins: [createPinia()] },
})
// Let all promises resolve
await new Promise(r => setTimeout(r, 0))
await w.vm.$nextTick()
expect(w.find('.empty-state').exists()).toBe(true)
})
it('renders header with action buttons', async () => {
const w = mount(LabelView, {
global: { plugins: [createPinia()] },
})
await new Promise(r => setTimeout(r, 0))
await w.vm.$nextTick()
expect(w.find('.lv-header').exists()).toBe(true)
expect(w.text()).toContain('Undo')
expect(w.text()).toContain('Skip')
expect(w.text()).toContain('Discard')
})
})

318
web/src/views/LabelView.vue Normal file
View file

@ -0,0 +1,318 @@
<template>
<div class="label-view">
<!-- Header bar -->
<header class="lv-header">
<span class="queue-count">
<template v-if="store.totalRemaining > 0">
{{ store.totalRemaining }} remaining
</template>
<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>
</span>
<div class="header-actions">
<button @click="handleUndo" :disabled="!store.lastAction" class="btn-action"> Undo</button>
<button @click="handleSkip" :disabled="!store.current" class="btn-action"> Skip</button>
<button @click="handleDiscard" :disabled="!store.current" class="btn-action btn-danger"> Discard</button>
</div>
</header>
<!-- States -->
<div v-if="loading" class="skeleton-card" aria-label="Loading email" />
<div v-else-if="apiError" class="error-display" role="alert">
<p>Couldn't reach Avocet API.</p>
<button @click="fetchBatch" class="btn-action">Retry</button>
</div>
<div v-else-if="!store.current" class="empty-state">
<p>Queue is empty fetch more emails to continue.</p>
</div>
<!-- Card stack + label grid -->
<template v-else>
<div class="card-stack-wrapper">
<EmailCardStack
:item="store.current"
:is-bucket-mode="isDragging"
:dismiss-type="dismissType"
@label="handleLabel"
@skip="handleSkip"
@discard="handleDiscard"
@drag-start="isDragging = true"
@drag-end="isDragging = false"
/>
</div>
<LabelBucketGrid
:labels="labels"
:is-bucket-mode="isDragging"
@label="handleLabel"
/>
</template>
<!-- Undo toast -->
<UndoToast
v-if="store.lastAction"
:action="store.lastAction"
@undo="handleUndo"
/>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useLabelStore } from '../stores/label'
import { useApiFetch } from '../composables/useApi'
import { useHaptics } from '../composables/useHaptics'
import { useMotion } from '../composables/useMotion'
import { useLabelKeyboard } from '../composables/useLabelKeyboard'
import EmailCardStack from '../components/EmailCardStack.vue'
import LabelBucketGrid from '../components/LabelBucketGrid.vue'
import UndoToast from '../components/UndoToast.vue'
const store = useLabelStore()
const haptics = useHaptics()
const motion = useMotion() // only needed to pass to child actual value used in App.vue
const loading = ref(true)
const apiError = ref(false)
const isDragging = ref(false)
const labels = ref<any[]>([])
const dismissType = ref<'label' | 'skip' | 'discard' | null>(null)
// Easter egg state
const consecutiveLabeled = ref(0)
const recentLabels = ref<number[]>([])
const onRoll = ref(false)
const speedRound = ref(false)
const fiftyDeep = ref(false)
async function fetchBatch() {
loading.value = true
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 }
store.queue = data.items
store.totalRemaining = data.total
}
function checkSpeedRound(): boolean {
const now = Date.now()
recentLabels.value = recentLabels.value.filter(t => now - t < 20000)
recentLabels.value.push(now)
return recentLabels.value.length >= 5
}
async function handleLabel(name: string) {
const item = store.current
if (!item) return
// Optimistic update
store.setLastAction('label', item, name)
dismissType.value = 'label'
if (motion.rich.value) {
await new Promise(r => setTimeout(r, 350))
}
store.removeCurrentFromQueue()
store.incrementLabeled()
dismissType.value = null
consecutiveLabeled.value++
haptics.label()
// Easter eggs
if (consecutiveLabeled.value >= 10) {
onRoll.value = true
setTimeout(() => { onRoll.value = false }, 3000)
}
if (store.sessionLabeled === 50) {
fiftyDeep.value = true
setTimeout(() => { fiftyDeep.value = false }, 5000)
}
if (checkSpeedRound()) {
onRoll.value = false
speedRound.value = true
setTimeout(() => { speedRound.value = false }, 2500)
}
await useApiFetch('/api/label', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: item.id, label: name }),
})
if (store.queue.length < 3) await fetchBatch()
}
async function handleSkip() {
const item = store.current
if (!item) return
store.setLastAction('skip', item)
dismissType.value = 'skip'
if (motion.rich.value) await new Promise(r => setTimeout(r, 300))
store.removeCurrentFromQueue()
dismissType.value = null
consecutiveLabeled.value = 0
haptics.skip()
await useApiFetch('/api/skip', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: item.id }),
})
}
async function handleDiscard() {
const item = store.current
if (!item) return
store.setLastAction('discard', item)
dismissType.value = 'discard'
if (motion.rich.value) await new Promise(r => setTimeout(r, 400))
store.removeCurrentFromQueue()
dismissType.value = null
consecutiveLabeled.value = 0
haptics.discard()
await useApiFetch('/api/discard', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: item.id }),
})
}
async function handleUndo() {
const { data } = await useApiFetch<{ undone: { type: string; item: any } }>('/api/label/undo', { method: 'DELETE' })
if (data?.undone?.item) {
store.restoreItem(data.undone.item)
store.clearLastAction()
haptics.undo()
if (data.undone.type === 'label') {
// decrement session counter sessionLabeled is direct state in a setup store
if (store.sessionLabeled > 0) store.sessionLabeled--
}
}
}
useLabelKeyboard({
labels: [], // will be updated after labels load keyboard not active until queue loads
onLabel: handleLabel,
onSkip: handleSkip,
onDiscard: handleDiscard,
onUndo: handleUndo,
onHelp: () => { /* TODO: help overlay */ },
})
onMounted(async () => {
const { data } = await useApiFetch<any[]>('/api/config/labels')
if (data) labels.value = data
await fetchBatch()
})
</script>
<style scoped>
.label-view {
display: flex;
flex-direction: column;
gap: 1.5rem;
padding: 1rem;
max-width: 640px;
margin: 0 auto;
min-height: 100dvh;
}
.lv-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
flex-wrap: wrap;
}
.queue-count {
font-family: var(--font-mono, monospace);
font-size: 0.9rem;
color: var(--color-text-secondary, #6b7a99);
display: flex;
align-items: center;
gap: 0.5rem;
}
.badge {
display: inline-block;
padding: 0.15rem 0.5rem;
border-radius: 999px;
font-size: 0.75rem;
font-weight: 700;
font-family: var(--font-body, sans-serif);
animation: badge-pop 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
}
@keyframes badge-pop {
from { transform: scale(0.6); opacity: 0; }
to { transform: scale(1); opacity: 1; }
}
.badge-roll { background: #ff6b35; color: #fff; }
.badge-speed { background: #7c3aed; color: #fff; }
.badge-fifty { background: var(--app-accent, #B8622A); color: var(--app-accent-text, #1a2338); }
.header-actions {
display: flex;
gap: 0.5rem;
}
.btn-action {
padding: 0.4rem 0.8rem;
border-radius: 0.375rem;
border: 1px solid var(--color-border, #d0d7e8);
background: var(--color-surface, #fff);
color: var(--color-text, #1a2338);
font-size: 0.85rem;
cursor: pointer;
transition: background 0.15s;
}
.btn-action:hover:not(:disabled) {
background: var(--app-primary-light, #E4F0F7);
}
.btn-action:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.btn-danger {
border-color: var(--color-error, #ef4444);
color: var(--color-error, #ef4444);
}
.skeleton-card {
min-height: 200px;
border-radius: var(--radius-card, 1rem);
background: linear-gradient(
90deg,
var(--color-surface-raised, #f0f4fc) 25%,
var(--color-surface, #e4ebf5) 50%,
var(--color-surface-raised, #f0f4fc) 75%
);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
}
@keyframes shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
.error-display, .empty-state {
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
padding: 3rem 1rem;
color: var(--color-text-secondary, #6b7a99);
text-align: center;
}
.card-stack-wrapper {
flex: 1;
}
</style>