From 015df2df53fa8bd87da4da896c1a9a3f07070129 Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Tue, 17 Mar 2026 22:30:33 -0700 Subject: [PATCH] feat(vue-spa): JobReviewView card stack with swipe gestures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - stores/review.ts: Pinia setup store — pending queue, undo stack, stoop-speed session timer (easter egg 9.2: 10 cards/60s) - components/JobCard.vue: card content with match-score badge (colored pill), keyword-gap pills, expand/collapse description, footer with job URL + relative date; shimmer animation for ≥95% matches (ee 9.4) - components/JobCardStack.vue: pointer-event drag with setPointerCapture, rolling 50ms velocity buffer for fling detection (600px/s + cos45° alignment), left/right color-tint overlay (red/green), spring snap-back on no-action, buffered exit animation before emitting approve/reject - views/JobReviewView.vue: segmented status tabs, card stack for pending, list view for other statuses, action buttons, keyboard shortcuts (←/J reject, →/L approve, S skip, Z undo, ? help), help overlay, undo toast (5s), falcon stoop empty state (easter egg 9.3) --- web/package-lock.json | 10 + web/package.json | 1 + web/src/components/JobCard.vue | 282 ++++++++++ web/src/components/JobCardStack.vue | 298 ++++++++++ web/src/stores/review.ts | 144 +++++ web/src/views/JobReviewView.vue | 834 +++++++++++++++++++++++++++- 6 files changed, 1560 insertions(+), 9 deletions(-) create mode 100644 web/src/components/JobCard.vue create mode 100644 web/src/components/JobCardStack.vue create mode 100644 web/src/stores/review.ts diff --git a/web/package-lock.json b/web/package-lock.json index c7f8b2a..7bb7295 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -11,6 +11,7 @@ "@fontsource/atkinson-hyperlegible": "^5.2.8", "@fontsource/fraunces": "^5.2.9", "@fontsource/jetbrains-mono": "^5.2.8", + "@heroicons/vue": "^2.2.0", "@vueuse/core": "^14.2.1", "@vueuse/integrations": "^14.2.1", "animejs": "^4.3.6", @@ -828,6 +829,15 @@ "url": "https://github.com/sponsors/ayuhito" } }, + "node_modules/@heroicons/vue": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@heroicons/vue/-/vue-2.2.0.tgz", + "integrity": "sha512-G3dbSxoeEKqbi/DFalhRxJU4mTXJn7GwZ7ae8NuEQzd1bqdd0jAbdaBZlHPcvPD2xI1iGzNVB4k20Un2AguYPw==", + "license": "MIT", + "peerDependencies": { + "vue": ">= 3" + } + }, "node_modules/@iconify/types": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@iconify/types/-/types-2.0.0.tgz", diff --git a/web/package.json b/web/package.json index 8ab0ea2..77ddd18 100644 --- a/web/package.json +++ b/web/package.json @@ -14,6 +14,7 @@ "@fontsource/atkinson-hyperlegible": "^5.2.8", "@fontsource/fraunces": "^5.2.9", "@fontsource/jetbrains-mono": "^5.2.8", + "@heroicons/vue": "^2.2.0", "@vueuse/core": "^14.2.1", "@vueuse/integrations": "^14.2.1", "animejs": "^4.3.6", diff --git a/web/src/components/JobCard.vue b/web/src/components/JobCard.vue new file mode 100644 index 0000000..18e3ce0 --- /dev/null +++ b/web/src/components/JobCard.vue @@ -0,0 +1,282 @@ + + + + + diff --git a/web/src/components/JobCardStack.vue b/web/src/components/JobCardStack.vue new file mode 100644 index 0000000..ffbb9b6 --- /dev/null +++ b/web/src/components/JobCardStack.vue @@ -0,0 +1,298 @@ + + + + + diff --git a/web/src/stores/review.ts b/web/src/stores/review.ts new file mode 100644 index 0000000..78a282b --- /dev/null +++ b/web/src/stores/review.ts @@ -0,0 +1,144 @@ +import { defineStore } from 'pinia' +import { ref, computed } from 'vue' +import { useApiFetch } from '../composables/useApi' + +export interface Job { + id: number + title: string + company: string + url: string + source: string | null + location: string | null + is_remote: boolean + salary: string | null + description: string | null + match_score: number | null + keyword_gaps: string | null // JSON-encoded string[] + date_found: string + status: string +} + +interface UndoEntry { + job: Job + action: 'approve' | 'reject' | 'skip' + prevStatus: string +} + +// Stoop speed: 10 cards in 60 seconds — easter egg 9.2 +const STOOP_CARDS = 10 +const STOOP_SECS = 60 + +export const useReviewStore = defineStore('review', () => { + const queue = ref([]) + const listJobs = ref([]) + const loading = ref(false) + const error = ref(null) + + const undoStack = ref([]) + const sessionStart = ref(null) + const sessionCount = ref(0) + const stoopAchieved = ref(false) + + const currentJob = computed(() => queue.value[0] ?? null) + const remaining = computed(() => queue.value.length) + + const isStoopSpeed = computed(() => { + if (stoopAchieved.value || !sessionStart.value) return false + const elapsed = (Date.now() - sessionStart.value) / 1000 + return sessionCount.value >= STOOP_CARDS && elapsed <= STOOP_SECS + }) + + async function fetchQueue() { + loading.value = true + error.value = null + const { data, error: err } = await useApiFetch('/api/jobs?status=pending&limit=50') + loading.value = false + if (err) { error.value = 'Failed to load queue'; return } + queue.value = data ?? [] + // Start session clock on first load with items + if (!sessionStart.value && queue.value.length > 0) { + sessionStart.value = Date.now() + sessionCount.value = 0 + } + } + + async function fetchList(status: string) { + loading.value = true + error.value = null + const { data, error: err } = await useApiFetch(`/api/jobs?status=${encodeURIComponent(status)}`) + loading.value = false + if (err) { error.value = 'Failed to load jobs'; return } + listJobs.value = data ?? [] + } + + async function approve(job: Job) { + const { error: err } = await useApiFetch(`/api/jobs/${job.id}/approve`, { method: 'POST' }) + if (err) return false + undoStack.value.push({ job, action: 'approve', prevStatus: job.status }) + queue.value = queue.value.filter(j => j.id !== job.id) + _tickSession() + return true + } + + async function reject(job: Job) { + const { error: err } = await useApiFetch(`/api/jobs/${job.id}/reject`, { method: 'POST' }) + if (err) return false + undoStack.value.push({ job, action: 'reject', prevStatus: job.status }) + queue.value = queue.value.filter(j => j.id !== job.id) + _tickSession() + return true + } + + function skip(job: Job) { + // Skip: move current card to back of queue without API call + queue.value = queue.value.filter(j => j.id !== job.id) + queue.value.push(job) + undoStack.value.push({ job, action: 'skip', prevStatus: job.status }) + _tickSession() + return true + } + + async function undo() { + const entry = undoStack.value.pop() + if (!entry) return false + const { job, action } = entry + if (action === 'skip') { + // Was at back of queue — remove from wherever it landed, put at front + queue.value = queue.value.filter(j => j.id !== job.id) + queue.value.unshift(job) + } else { + await useApiFetch(`/api/jobs/${job.id}/revert`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ status: entry.prevStatus }), + }) + queue.value.unshift(job) + } + sessionCount.value = Math.max(0, sessionCount.value - 1) + return true + } + + function _tickSession() { + sessionCount.value++ + } + + function markStoopAchieved() { + stoopAchieved.value = true + } + + function resetSession() { + sessionStart.value = Date.now() + sessionCount.value = 0 + stoopAchieved.value = false + } + + return { + queue, listJobs, loading, error, + undoStack, + currentJob, remaining, + sessionCount, isStoopSpeed, stoopAchieved, + fetchQueue, fetchList, + approve, reject, skip, undo, + markStoopAchieved, resetSession, + } +}) diff --git a/web/src/views/JobReviewView.vue b/web/src/views/JobReviewView.vue index 5263b60..02718ba 100644 --- a/web/src/views/JobReviewView.vue +++ b/web/src/views/JobReviewView.vue @@ -1,18 +1,834 @@ -