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:
parent
b623d252d0
commit
382bca28a1
3 changed files with 395 additions and 22 deletions
|
|
@ -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>
|
||||
|
|
|
|||
46
web/src/views/LabelView.test.ts
Normal file
46
web/src/views/LabelView.test.ts
Normal 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
318
web/src/views/LabelView.vue
Normal 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>
|
||||
Loading…
Reference in a new issue