Merge branch 'feat/T3-web'

This commit is contained in:
hermes 2026-07-30 18:05:31 +00:00
commit 8eb8400bad
25 changed files with 7068 additions and 0 deletions

12
apps/web/index.html Normal file
View file

@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Jobhunt Platform</title>
</head>
<body class="bg-gray-50 text-gray-900">
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

5431
apps/web/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

29
apps/web/package.json Normal file
View file

@ -0,0 +1,29 @@
{
"name": "jobhunt-web",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc --noEmit && vite build",
"preview": "vite preview",
"test": "vitest run"
},
"dependencies": {
"pinia": "^2.2.6",
"vue": "^3.5.13",
"vue-router": "^4.4.5"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.2.1",
"@vue/test-utils": "^2.4.6",
"autoprefixer": "^10.4.20",
"jsdom": "^25.0.1",
"postcss": "^8.4.49",
"tailwindcss": "^3.4.17",
"typescript": "^5.7.2",
"vite": "^6.0.3",
"vitest": "^2.1.8",
"vue-tsc": "^2.1.10"
}
}

View file

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {}
}
}

23
apps/web/src/App.vue Normal file
View file

@ -0,0 +1,23 @@
<script setup lang="ts">
import { RouterLink, RouterView } from 'vue-router'
import ToastHost from './components/ToastHost.vue'
</script>
<template>
<div class="min-h-screen flex flex-col">
<header class="bg-white border-b border-gray-200 shadow-sm">
<div class="max-w-7xl mx-auto px-4 py-3 flex items-center gap-6">
<span class="text-lg font-bold text-indigo-700">Jobhunt</span>
<nav class="flex gap-4 text-sm">
<RouterLink to="/cv" class="text-gray-600 hover:text-indigo-700">CV</RouterLink>
<RouterLink to="/research" class="text-gray-600 hover:text-indigo-700">Research</RouterLink>
<RouterLink to="/applications" class="text-gray-600 hover:text-indigo-700">Applications</RouterLink>
</nav>
</div>
</header>
<main class="flex-1 max-w-7xl mx-auto w-full px-4 py-6">
<RouterView />
</main>
<ToastHost />
</div>
</template>

170
apps/web/src/api/index.ts Normal file
View file

@ -0,0 +1,170 @@
// API client module: thin typed fetch wrappers over the contract endpoints.
// Base URL from VITE_API_BASE, defaulting to http://localhost:8000/api.
import type {
AiAssistResponse,
Application,
Approval,
Artifact,
CoverLetterResponse,
CritiqueComment,
CvSection,
JobPosting,
Profile,
RenderCvResponse,
ScoreResponse
} from '@/types'
const API_BASE: string =
import.meta.env.VITE_API_BASE ?? 'http://localhost:8000/api'
export class HttpError extends Error {
status: number
body: unknown
constructor(status: number, body: unknown, message?: string) {
super(message ?? `HTTP ${status}`)
this.status = status
this.body = body
}
}
async function request<T>(
path: string,
options: RequestInit = {}
): Promise<T> {
const res = await fetch(`${API_BASE}${path}`, {
headers: { 'Content-Type': 'application/json', ...options.headers },
...options
})
if (!res.ok) {
let body: unknown = null
try {
body = await res.json()
} catch {
// non-JSON error body
}
throw new HttpError(res.status, body, `HTTP ${res.status}`)
}
return res.json() as Promise<T>
}
// --- Profile & CV ---
export function getProfile(): Promise<Profile> {
return request<Profile>('/profile')
}
export function updateProfile(data: Partial<Profile>): Promise<Profile> {
return request<Profile>('/profile', { method: 'PUT', body: JSON.stringify(data) })
}
export function getSections(): Promise<CvSection[]> {
return request<CvSection[]>('/profile/sections')
}
export function createSection(data: Partial<CvSection>): Promise<CvSection> {
return request<CvSection>('/profile/sections', { method: 'POST', body: JSON.stringify(data) })
}
export function updateSection(id: string, data: Partial<CvSection>): Promise<CvSection> {
return request<CvSection>(`/profile/sections/${id}`, { method: 'PUT', body: JSON.stringify(data) })
}
export function deleteSection(id: string): Promise<void> {
return request<void>(`/profile/sections/${id}`, { method: 'DELETE' })
}
export function aiAssist(sectionId: string, instruction: string): Promise<AiAssistResponse> {
return request<AiAssistResponse>(`/profile/sections/${sectionId}/ai-assist`, {
method: 'POST',
body: JSON.stringify({ instruction })
})
}
export function renderCv(): Promise<RenderCvResponse> {
return request<RenderCvResponse>('/profile/render-cv', { method: 'POST' })
}
// --- Job postings ---
export function getPostings(): Promise<JobPosting[]> {
return request<JobPosting[]>('/postings')
}
export function createPosting(url: string): Promise<JobPosting> {
return request<JobPosting>('/postings', { method: 'POST', body: JSON.stringify({ url }) })
}
export function scorePosting(postingId: string): Promise<ScoreResponse> {
return request<ScoreResponse>(`/postings/${postingId}/score`, { method: 'POST' })
}
// --- Applications ---
export function getApplications(): Promise<Application[]> {
return request<Application[]>('/applications')
}
export function transitionApplication(id: string, to: string): Promise<Application> {
return request<Application>(`/applications/${id}/transition`, {
method: 'POST',
body: JSON.stringify({ to })
})
}
export function getArtifacts(applicationId: string): Promise<Artifact[]> {
return request<Artifact[]>(`/applications/${applicationId}/artifacts`)
}
export function createCoverLetter(
applicationId: string,
letterText: string
): Promise<CoverLetterResponse> {
return request<CoverLetterResponse>(`/applications/${applicationId}/artifacts/cover-letter`, {
method: 'POST',
body: JSON.stringify({ letter_text: letterText })
})
}
// --- Approval & outbox ---
export function createApproval(
applicationId: string,
action: string,
artifactId: string
): Promise<Approval> {
return request<Approval>(`/applications/${applicationId}/approvals`, {
method: 'POST',
body: JSON.stringify({ action, artifact_id: artifactId })
})
}
export function confirmApproval(approvalId: string): Promise<Approval> {
return request<Approval>(`/approvals/${approvalId}/confirm`, { method: 'POST' })
}
export function rejectApproval(approvalId: string): Promise<Approval> {
return request<Approval>(`/approvals/${approvalId}/reject`, { method: 'POST' })
}
export function outboxSend(approvalId: string, payload: Record<string, unknown>): Promise<unknown> {
return request<unknown>('/outbox/send', {
method: 'POST',
body: JSON.stringify({ approval_id: approvalId, payload })
})
}
// Re-export types for convenience
export type {
AiAssistResponse,
Application,
Approval,
Artifact,
CoverLetterResponse,
CritiqueComment,
CvSection,
JobPosting,
Profile,
RenderCvResponse,
ScoreResponse
}

View file

@ -0,0 +1,21 @@
<script setup lang="ts">
import { useToastStore } from '@/stores/toast'
const toastStore = useToastStore()
</script>
<template>
<div class="fixed bottom-4 right-4 z-50 flex flex-col gap-2">
<div
v-for="t in toastStore.toasts"
:key="t.id"
class="px-4 py-2 rounded shadow-lg text-sm text-white max-w-sm"
:class="{
'bg-gray-800': t.type === 'info',
'bg-red-600': t.type === 'error',
'bg-green-600': t.type === 'success'
}"
>
{{ t.message }}
</div>
</div>
</template>

10
apps/web/src/main.ts Normal file
View file

@ -0,0 +1,10 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import router from './router'
import './style.css'
const app = createApp(App)
app.use(createPinia())
app.use(router)
app.mount('#app')

View file

@ -0,0 +1,33 @@
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import { createRouter, createMemoryHistory } from 'vue-router'
import App from '@/App.vue'
function makeRouter() {
return createRouter({
history: createMemoryHistory(),
routes: [
{ path: '/', redirect: '/cv' },
{ path: '/cv', name: 'cv', component: { template: '<div>CV</div>' } },
{ path: '/research', name: 'research', component: { template: '<div>Research</div>' } },
{ path: '/applications', name: 'applications', component: { template: '<div>Applications</div>' } },
{ path: '/applications/:id', name: 'application-detail', component: { template: '<div>Detail</div>' } }
]
})
}
describe('Router tabs', () => {
it('renders all three tab links', async () => {
setActivePinia(createPinia())
const router = makeRouter()
await router.push('/cv')
await router.isReady()
const wrapper = mount(App, { global: { plugins: [router] } })
const links = wrapper.findAll('nav a')
expect(links).toHaveLength(3)
expect(links[0].text()).toBe('CV')
expect(links[1].text()).toBe('Research')
expect(links[2].text()).toBe('Applications')
})
})

View file

@ -0,0 +1,34 @@
import { createRouter, createWebHistory } from 'vue-router'
import type { RouteRecordRaw } from 'vue-router'
const routes: RouteRecordRaw[] = [
{ path: '/', redirect: '/cv' },
{
path: '/cv',
name: 'cv',
component: () => import('@/views/CvEditor.vue')
},
{
path: '/research',
name: 'research',
component: () => import('@/views/Research.vue')
},
{
path: '/applications',
name: 'applications',
component: () => import('@/views/Applications.vue')
},
{
path: '/applications/:id',
name: 'application-detail',
component: () => import('@/views/ApplicationDetail.vue'),
props: true
}
]
const router = createRouter({
history: createWebHistory(),
routes
})
export default router

View file

@ -0,0 +1,28 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
export interface Toast {
id: number
message: string
type: 'info' | 'error' | 'success'
}
let nextId = 0
export const useToastStore = defineStore('toast', () => {
const toasts = ref<Toast[]>([])
function push(message: string, type: Toast['type'] = 'info'): number {
const id = ++nextId
toasts.value.push({ id, message, type })
setTimeout(() => dismiss(id), 5000)
return id
}
function dismiss(id: number): void {
const idx = toasts.value.findIndex((t) => t.id === id)
if (idx !== -1) toasts.value.splice(idx, 1)
}
return { toasts, push, dismiss }
})

3
apps/web/src/style.css Normal file
View file

@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

128
apps/web/src/types/index.ts Normal file
View file

@ -0,0 +1,128 @@
// Domain types matching the API contract (docs/api-contract.md + docs/data-model.md)
export interface Profile {
id: string
full_name: string
email: string
phone: string
location: string
headline: string
summary: string
languages: { code: string; level: string }[]
hard_rules: Record<string, unknown>
created_at?: string
updated_at?: string
}
export type CvSectionKind = 'experience' | 'education' | 'skills' | 'projects' | 'other'
export interface CvSection {
id: string
profile_id: string
kind: CvSectionKind
title: string
org: string
location: string
start_date: string
end_date: string
bullets: string[]
tags: string[]
sort_order: number
created_at?: string
updated_at?: string
}
export interface JobPosting {
id: string
source: string
external_id: string | null
url: string
company: string
title: string
location: string
description: string
raw: Record<string, unknown>
fetched_at: string
}
export type ApplicationState =
| 'discovered'
| 'scored'
| 'approved'
| 'rejected'
| 'drafting'
| 'sent'
| 'interviewing'
| 'offer'
| 'closed'
| 'expired'
export interface Application {
id: string
job_posting_id: string
state: ApplicationState
score: number | null
score_rationale: Record<string, unknown> | null
notes: string
state_changed_at: string
created_at: string
// joined posting info (from GET /applications)
posting?: JobPosting
}
export type ArtifactKind = 'cv' | 'cover_letter' | 'email' | 'other'
export interface Artifact {
id: string
application_id: string
kind: ArtifactKind
filename: string
content_hash: string
storage_path: string
version: number
origin: 'user_drafted' | 'ai_drafted' | 'ai_reviewed'
created_at: string
}
export type ApprovalAction = 'send_email' | 'submit_application'
export interface Approval {
id: string
application_id: string
artifact_id: string
artifact_hash: string
action: ApprovalAction
confirmed_by_user: boolean
confirmed_at: string | null
expires_at: string
created_at: string
}
export interface CritiqueComment {
quote: string
suggestion: string
severity: 'high' | 'medium' | 'low'
}
export interface CoverLetterResponse {
artifact: Artifact
comments: CritiqueComment[]
}
export interface ScoreResponse {
score: number
rationale: Record<string, unknown>
}
export interface RenderCvResponse {
artifact_id: string
url: string
}
export interface AiAssistResponse {
suggestions: string[]
}
export interface ApiError {
error: { code: string; message: string }
}

View file

@ -0,0 +1,153 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import type { Application, Artifact, Approval } from '@/types'
// Mock the api module inline so vi.mock factory is self-contained
vi.mock('@/api', () => ({
getApplications: vi.fn(),
getArtifacts: vi.fn(),
createCoverLetter: vi.fn(),
createApproval: vi.fn(),
confirmApproval: vi.fn(),
rejectApproval: vi.fn(),
outboxSend: vi.fn(),
HttpError: class HttpError extends Error {
status: number
body: unknown
constructor(status: number, body: unknown, msg?: string) {
super(msg ?? `HTTP ${status}`)
this.status = status
this.body = body
}
}
}))
function makeApp(): Application {
return {
id: 'app-1',
job_posting_id: 'j-1',
state: 'drafting',
score: 90,
score_rationale: null,
notes: '',
state_changed_at: '2026-01-01T00:00:00Z',
created_at: '2026-01-01T00:00:00Z',
posting: {
id: 'j-1', source: 'manual_url', external_id: null, url: 'http://x',
company: 'Acme', title: 'Engineer', location: 'Remote', description: '',
raw: {}, fetched_at: '2026-01-01T00:00:00Z'
}
}
}
function makeArtifact(): Artifact {
return {
id: 'art-1',
application_id: 'app-1',
kind: 'cover_letter',
filename: 'cover.pdf',
content_hash: 'abcdef0123456789',
storage_path: '/tmp/cover.pdf',
version: 1,
origin: 'user_drafted',
created_at: '2026-01-01T00:00:00Z'
}
}
function makeApproval(confirmed: boolean): Approval {
return {
id: 'appr-1',
application_id: 'app-1',
artifact_id: 'art-1',
artifact_hash: 'abcdef0123456789',
action: 'send_email',
confirmed_by_user: confirmed,
confirmed_at: confirmed ? '2026-01-01T00:00:00Z' : null,
expires_at: '2026-01-02T00:00:00Z',
created_at: '2026-01-01T00:00:00Z'
}
}
async function mountDetail(app: Application, artifacts: Artifact[]) {
const pinia = createPinia()
setActivePinia(pinia)
const api = await import('@/api')
;(api.getApplications as ReturnType<typeof vi.fn>).mockResolvedValue([app])
;(api.getArtifacts as ReturnType<typeof vi.fn>).mockResolvedValue(artifacts)
const ApplicationDetail = (await import('@/views/ApplicationDetail.vue')).default
const wrapper = mount(ApplicationDetail, { props: { id: 'app-1' } })
await flushPromises()
return { wrapper, pinia }
}
/** Find the Send button (has class ml-2 to distinguish from the Save button) */
function findSendBtn(wrapper: ReturnType<typeof mount>): ReturnType<typeof wrapper.find> {
return wrapper.find('button.ml-2')
}
describe('Approval widget', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('disables Send until confirmed', async () => {
const { wrapper } = await mountDetail(makeApp(), [makeArtifact()])
const api = await import('@/api')
// Request approval (not confirmed yet)
;(api.createApproval as ReturnType<typeof vi.fn>).mockResolvedValue(makeApproval(false))
// Select artifact and action
const selects = wrapper.findAll('select')
await selects[0].setValue('art-1')
await selects[1].setValue('send_email')
// Click Request Approval (bg-gray-700)
await wrapper.find('button.bg-gray-700').trigger('click')
await flushPromises()
// Send button should be disabled because not confirmed
const sendBtn = findSendBtn(wrapper)
expect(sendBtn.exists()).toBe(true)
expect(sendBtn.attributes('disabled')).toBeDefined()
// Now confirm
;(api.confirmApproval as ReturnType<typeof vi.fn>).mockResolvedValue(makeApproval(true))
const confirmBtn = wrapper.find('button.bg-green-600')
await confirmBtn.trigger('click')
await flushPromises()
// Send button should now be enabled
expect(sendBtn.attributes('disabled')).toBeUndefined()
})
it('shows error toast on 409 send failure', async () => {
const { wrapper, pinia } = await mountDetail(makeApp(), [makeArtifact()])
const api = await import('@/api')
// Setup: approval already confirmed
;(api.createApproval as ReturnType<typeof vi.fn>).mockResolvedValue(makeApproval(true))
const selects = wrapper.findAll('select')
await selects[0].setValue('art-1')
await selects[1].setValue('send_email')
await wrapper.find('button.bg-gray-700').trigger('click')
await flushPromises()
// Simulate 409 on send
const httpErr = new api.HttpError(409, { error: { code: 'HASH_MISMATCH', message: 'Hash mismatch' } })
;(api.outboxSend as ReturnType<typeof vi.fn>).mockRejectedValue(httpErr)
const sendBtn = findSendBtn(wrapper)
await sendBtn.trigger('click')
await flushPromises()
// Check the toast store for the error message
const toastState = pinia.state.value.toast
expect(toastState).toBeTruthy()
expect(toastState.toasts.length).toBeGreaterThanOrEqual(1)
const errorToasts = toastState.toasts.filter((t: { type: string }) => t.type === 'error')
expect(errorToasts.length).toBeGreaterThanOrEqual(1)
expect(errorToasts[0].message).toContain('Hash mismatch')
})
})

View file

@ -0,0 +1,251 @@
<script setup lang="ts">
import { onMounted, ref, computed } from 'vue'
import { useToastStore } from '@/stores/toast'
import * as api from '@/api'
import { HttpError } from '@/api'
import type {
Application,
Artifact,
Approval,
ApprovalAction,
CoverLetterResponse,
CritiqueComment
} from '@/types'
const props = defineProps<{ id: string }>()
const toast = useToastStore()
const application = ref<Application | null>(null)
const artifacts = ref<Artifact[]>([])
const loading = ref(true)
// Cover letter editor
const letterText = ref('')
const critique = ref<CritiqueComment[]>([])
const savingLetter = ref(false)
// Approval widget
const selectedArtifactId = ref('')
const selectedAction = ref<ApprovalAction>('send_email')
const approval = ref<Approval | null>(null)
const requestingApproval = ref(false)
const confirming = ref(false)
const sending = ref(false)
const isConfirmed = computed(() => approval.value?.confirmed_by_user ?? false)
const canSend = computed(() => isConfirmed.value && !sending.value)
const actions: ApprovalAction[] = ['send_email', 'submit_application']
const severityClass: Record<string, string> = {
high: 'bg-red-50 border-red-200',
medium: 'bg-yellow-50 border-yellow-200',
low: 'bg-blue-50 border-blue-200'
}
async function loadData() {
try {
const apps = await api.getApplications()
application.value = apps.find((a) => a.id === props.id) ?? null
artifacts.value = await api.getArtifacts(props.id)
} catch {
toast.push('Failed to load application', 'error')
} finally {
loading.value = false
}
}
async function saveCoverLetter() {
savingLetter.value = true
critique.value = []
try {
const res: CoverLetterResponse = await api.createCoverLetter(props.id, letterText.value)
artifacts.value = [res.artifact, ...artifacts.value.filter((a) => a.id !== res.artifact.id)]
critique.value = res.comments
toast.push('Cover letter saved with critique', 'success')
} catch {
toast.push('Failed to save cover letter', 'error')
} finally {
savingLetter.value = false
}
}
async function requestApproval() {
if (!selectedArtifactId.value) {
toast.push('Select an artifact first', 'error')
return
}
requestingApproval.value = true
try {
approval.value = await api.createApproval(props.id, selectedAction.value, selectedArtifactId.value)
toast.push('Approval requested', 'success')
} catch (err) {
let msg = 'Failed to request approval'
if (err instanceof HttpError) {
const body = err.body as { error?: { message?: string } } | null
msg = body?.error?.message ?? msg
}
toast.push(msg, 'error')
} finally {
requestingApproval.value = false
}
}
async function confirmApprovalAction() {
if (!approval.value) return
confirming.value = true
try {
approval.value = await api.confirmApproval(approval.value.id)
toast.push('Approval confirmed', 'success')
} catch (err) {
let msg = 'Confirm failed'
if (err instanceof HttpError) {
const body = err.body as { error?: { message?: string } } | null
msg = body?.error?.message ?? msg
}
toast.push(msg, 'error')
} finally {
confirming.value = false
}
}
async function sendOutbox() {
if (!approval.value || !canSend.value) return
sending.value = true
try {
await api.outboxSend(approval.value.id, { to: application.value?.posting?.company ?? '' })
toast.push('Sent successfully', 'success')
} catch (err) {
let msg = 'Send failed'
if (err instanceof HttpError) {
const body = err.body as { error?: { message?: string } } | null
msg = body?.error?.message ?? msg
}
toast.push(msg, 'error')
} finally {
sending.value = false
}
}
onMounted(loadData)
</script>
<template>
<div class="space-y-6">
<h1 class="text-2xl font-bold">Application Detail</h1>
<div v-if="loading" class="text-gray-500">Loading...</div>
<template v-if="!loading && application">
<!-- Posting info -->
<section class="bg-white rounded-lg border border-gray-200 p-4">
<div class="font-semibold text-lg">{{ application.posting?.company ?? 'Unknown' }}</div>
<div class="text-gray-600">{{ application.posting?.title ?? 'No title' }}</div>
<div class="text-sm text-gray-500 mt-1">
State: <span class="capitalize font-medium">{{ application.state }}</span>
<span v-if="application.score != null" class="ml-3">Score: {{ application.score }}</span>
</div>
</section>
<!-- Artifacts list -->
<section class="bg-white rounded-lg border border-gray-200 p-4 space-y-2">
<h2 class="font-semibold">Artifacts</h2>
<ul v-if="artifacts.length" class="text-sm space-y-1">
<li v-for="a in artifacts" :key="a.id" class="flex justify-between border-b border-gray-100 py-1">
<span>{{ a.filename }} ({{ a.kind }})</span>
<span class="text-gray-400 text-xs">{{ a.content_hash.slice(0, 12) }}</span>
</li>
</ul>
<p v-else class="text-sm text-gray-400">No artifacts yet.</p>
</section>
<!-- Cover letter editor + critique -->
<section class="bg-white rounded-lg border border-gray-200 p-4 space-y-3">
<h2 class="font-semibold">Cover Letter</h2>
<textarea
v-model="letterText"
rows="8"
class="w-full border rounded px-2 py-1 text-sm"
placeholder="Write your cover letter..."
></textarea>
<button
@click="saveCoverLetter"
:disabled="savingLetter"
class="bg-indigo-600 text-white px-4 py-2 rounded text-sm"
>
{{ savingLetter ? 'Saving...' : 'Save & Get Critique' }}
</button>
<div v-if="critique.length" class="space-y-2 mt-3">
<h3 class="font-medium text-sm">Critique</h3>
<div
v-for="(c, i) in critique"
:key="i"
class="border rounded p-2 text-sm"
:class="severityClass[c.severity] ?? 'bg-gray-50 border-gray-200'"
>
<div class="font-medium capitalize">{{ c.severity }}</div>
<div class="text-gray-700 italic">"{{ c.quote }}"</div>
<div class="text-gray-600 mt-1">{{ c.suggestion }}</div>
</div>
</div>
</section>
<!-- Approval widget -->
<section class="bg-white rounded-lg border border-gray-200 p-4 space-y-3">
<h2 class="font-semibold">Approval & Send</h2>
<div class="flex gap-3 items-end">
<label class="block">
<span class="text-sm text-gray-600">Artifact</span>
<select v-model="selectedArtifactId" class="border rounded px-2 py-1 mt-1 text-sm">
<option value="" disabled>Select...</option>
<option v-for="a in artifacts" :key="a.id" :value="a.id">{{ a.filename }} ({{ a.kind }})</option>
</select>
</label>
<label class="block">
<span class="text-sm text-gray-600">Action</span>
<select v-model="selectedAction" class="border rounded px-2 py-1 mt-1 text-sm">
<option v-for="a in actions" :key="a" :value="a">{{ a }}</option>
</select>
</label>
<button
@click="requestApproval"
:disabled="requestingApproval || !selectedArtifactId"
class="bg-gray-700 text-white px-4 py-2 rounded text-sm"
>
{{ requestingApproval ? 'Requesting...' : 'Request Approval' }}
</button>
</div>
<div v-if="approval" class="space-y-2 border-t border-gray-100 pt-3">
<div class="text-sm">
Approval ID: <code>{{ approval.id.slice(0, 8) }}</code>
</div>
<div class="text-sm">
Confirmed: <span :class="isConfirmed ? 'text-green-700' : 'text-red-700'">{{ isConfirmed ? 'Yes' : 'No' }}</span>
</div>
<div class="text-sm text-gray-500">Expires: {{ approval.expires_at?.slice(0, 16) }}</div>
<button
@click="confirmApprovalAction"
:disabled="confirming || isConfirmed"
class="bg-green-600 text-white px-4 py-2 rounded text-sm"
>
{{ confirming ? 'Confirming...' : 'I Confirm' }}
</button>
<button
@click="sendOutbox"
:disabled="!canSend"
class="ml-2 bg-indigo-600 text-white px-4 py-2 rounded text-sm disabled:opacity-50 disabled:cursor-not-allowed"
>
{{ sending ? 'Sending...' : 'Send' }}
</button>
</div>
</section>
</template>
<div v-if="!loading && !application" class="text-gray-500">Application not found.</div>
</div>
</template>

View file

@ -0,0 +1,82 @@
import { describe, it, expect, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import Applications from '@/views/Applications.vue'
import type { Application } from '@/types'
vi.mock('@/api', () => ({
getApplications: vi.fn(),
transitionApplication: vi.fn(),
HttpError: class HttpError extends Error {
status: number
body: unknown
constructor(status: number, body: unknown, msg?: string) {
super(msg ?? `HTTP ${status}`)
this.status = status
this.body = body
}
}
}))
function fixture(): Application[] {
return [
{
id: 'app-1',
job_posting_id: 'j-1',
state: 'discovered',
score: null,
score_rationale: null,
notes: '',
state_changed_at: '2026-01-01T00:00:00Z',
created_at: '2026-01-01T00:00:00Z',
posting: {
id: 'j-1', source: 'manual_url', external_id: null, url: 'http://x',
company: 'Acme', title: 'Engineer', location: 'Remote', description: '',
raw: {}, fetched_at: '2026-01-01T00:00:00Z'
}
},
{
id: 'app-2',
job_posting_id: 'j-2',
state: 'scored',
score: 85,
score_rationale: null,
notes: '',
state_changed_at: '2026-01-01T00:00:00Z',
created_at: '2026-01-01T00:00:00Z',
posting: {
id: 'j-2', source: 'linkedin', external_id: null, url: 'http://y',
company: 'Globex', title: 'Manager', location: 'Malmo', description: '',
raw: {}, fetched_at: '2026-01-01T00:00:00Z'
}
}
]
}
describe('Applications kanban', () => {
it('groups cards by state from fixture', async () => {
setActivePinia(createPinia())
const { getApplications } = await import('@/api')
;(getApplications as ReturnType<typeof vi.fn>).mockResolvedValue(fixture())
const wrapper = mount(Applications)
// Wait for onMounted load
await vi.waitFor(() => {
expect(wrapper.text()).toContain('Acme')
expect(wrapper.text()).toContain('Globex')
})
// "discovered" column should contain Acme
const discoveredCol = wrapper.findAll('.font-semibold').find((el) => el.text() === 'discovered')
expect(discoveredCol).toBeTruthy()
const discoveredColumn = discoveredCol!.element.parentElement!
expect(discoveredColumn.textContent).toContain('Acme')
expect(discoveredColumn.textContent).not.toContain('Globex')
// "scored" column should contain Globex
const scoredCol = wrapper.findAll('.font-semibold').find((el) => el.text() === 'scored')
expect(scoredCol).toBeTruthy()
const scoredColumn = scoredCol!.element.parentElement!
expect(scoredColumn.textContent).toContain('Globex')
})
})

View file

@ -0,0 +1,133 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useToastStore } from '@/stores/toast'
import * as api from '@/api'
import { HttpError } from '@/api'
import type { Application, ApplicationState } from '@/types'
const toast = useToastStore()
const router = useRouter()
const applications = ref<Application[]>([])
const loading = ref(true)
const draggingId = ref<string | null>(null)
const draggingFrom = ref<ApplicationState | null>(null)
const states: ApplicationState[] = [
'discovered',
'scored',
'approved',
'drafting',
'sent',
'interviewing',
'offer',
'closed',
'rejected',
'expired'
]
function appsInState(state: ApplicationState): Application[] {
return applications.value.filter((a) => a.state === state)
}
async function loadApplications() {
try {
applications.value = await api.getApplications()
} catch {
toast.push('Failed to load applications', 'error')
} finally {
loading.value = false
}
}
function onDragStart(app: Application, state: ApplicationState, e: DragEvent) {
draggingId.value = app.id
draggingFrom.value = state
if (e.dataTransfer) {
e.dataTransfer.effectAllowed = 'move'
e.dataTransfer.setData('text/plain', app.id)
}
}
async function onDrop(targetState: ApplicationState, e: DragEvent) {
e.preventDefault()
const appId = draggingId.value
draggingId.value = null
draggingFrom.value = null
if (!appId) return
const app = applications.value.find((a) => a.id === appId)
if (!app || app.state === targetState) return
// Optimistic: move card immediately
const oldState = app.state
app.state = targetState
try {
await api.transitionApplication(appId, targetState)
} catch (err) {
// Revert on error (including 409)
app.state = oldState
let msg = 'Transition failed'
if (err instanceof HttpError) {
const body = err.body as { error?: { message?: string } } | null
msg = body?.error?.message ?? `HTTP ${err.status}`
if (err.status === 409) {
msg = `Rejected: ${msg}`
}
}
toast.push(msg, 'error')
}
}
function onDragOver(e: DragEvent) {
e.preventDefault()
if (e.dataTransfer) e.dataTransfer.dropEffect = 'move'
}
function goToDetail(app: Application) {
router.push(`/applications/${app.id}`)
}
onMounted(loadApplications)
</script>
<template>
<div class="space-y-4">
<h1 class="text-2xl font-bold">Applications</h1>
<div v-if="loading" class="text-gray-500">Loading...</div>
<div v-if="!loading" class="flex gap-3 overflow-x-auto pb-4">
<div
v-for="state in states"
:key="state"
class="flex-shrink-0 w-56 bg-gray-100 rounded-lg p-2 min-h-[200px]"
@drop="onDrop(state, $event)"
@dragover="onDragOver"
>
<div class="font-semibold text-sm text-gray-700 mb-2 capitalize">{{ state }}</div>
<div class="space-y-2">
<div
v-for="app in appsInState(state)"
:key="app.id"
draggable="true"
@dragstart="onDragStart(app, state, $event)"
@click="goToDetail(app)"
class="bg-white rounded border border-gray-200 p-2 cursor-pointer hover:shadow-md transition-shadow"
>
<div class="font-medium text-sm truncate">{{ app.posting?.company ?? 'Unknown' }}</div>
<div class="text-xs text-gray-500 truncate">{{ app.posting?.title ?? 'No title' }}</div>
<div v-if="app.score != null" class="text-xs text-green-700 mt-1">
Score: {{ app.score }}
</div>
</div>
<div v-if="appsInState(state).length === 0" class="text-xs text-gray-400 italic py-2 text-center">
-
</div>
</div>
</div>
</div>
</div>
</template>

View file

@ -0,0 +1,338 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useToastStore } from '@/stores/toast'
import * as api from '@/api'
import type { Profile, CvSection, CvSectionKind } from '@/types'
const toast = useToastStore()
const profile = ref<Profile | null>(null)
const sections = ref<CvSection[]>([])
const loading = ref(true)
const rendering = ref(false)
const renderUrl = ref<string | null>(null)
const sectionKinds: CvSectionKind[] = ['experience', 'education', 'skills', 'projects', 'other']
// Edit state for profile form
const form = ref({
full_name: '',
email: '',
phone: '',
location: '',
headline: '',
summary: ''
})
// New section draft
const newSection = ref({
kind: 'experience' as CvSectionKind,
title: '',
org: '',
location: '',
start_date: '',
end_date: '',
bullets: [] as string[],
tags: [] as string[]
})
const newBullet = ref('')
const newTag = ref('')
// AI assist state per section
const assistSectionId = ref<string | null>(null)
const assistInstruction = ref('')
const assistSuggestions = ref<string[]>([])
const assistLoading = ref(false)
async function loadProfile() {
try {
profile.value = await api.getProfile()
Object.assign(form.value, {
full_name: profile.value.full_name,
email: profile.value.email,
phone: profile.value.phone ?? '',
location: profile.value.location ?? '',
headline: profile.value.headline ?? '',
summary: profile.value.summary ?? ''
})
} catch {
toast.push('Failed to load profile', 'error')
}
}
async function loadSections() {
try {
sections.value = await api.getSections()
} catch {
toast.push('Failed to load sections', 'error')
}
}
async function saveProfile() {
try {
profile.value = await api.updateProfile(form.value)
toast.push('Profile saved', 'success')
} catch {
toast.push('Failed to save profile', 'error')
}
}
async function addSection() {
try {
const s = await api.createSection({
...newSection.value,
sort_order: sections.value.length
})
sections.value.push(s)
newSection.value = {
kind: 'experience',
title: '',
org: '',
location: '',
start_date: '',
end_date: '',
bullets: [],
tags: []
}
toast.push('Section added', 'success')
} catch {
toast.push('Failed to add section', 'error')
}
}
async function removeSection(id: string) {
try {
await api.deleteSection(id)
sections.value = sections.value.filter((s) => s.id !== id)
toast.push('Section deleted', 'success')
} catch {
toast.push('Failed to delete section', 'error')
}
}
async function saveSection(section: CvSection) {
try {
await api.updateSection(section.id, section)
toast.push('Section saved', 'success')
} catch {
toast.push('Failed to save section', 'error')
}
}
function addBullet() {
if (newBullet.value.trim()) {
newSection.value.bullets.push(newBullet.value.trim())
newBullet.value = ''
}
}
function addTag() {
if (newTag.value.trim()) {
newSection.value.tags.push(newTag.value.trim())
newTag.value = ''
}
}
function removeBullet(idx: number) {
newSection.value.bullets.splice(idx, 1)
}
async function callAiAssist(sectionId: string) {
assistLoading.value = true
assistSuggestions.value = []
try {
const res = await api.aiAssist(sectionId, assistInstruction.value || 'Improve this bullet')
assistSuggestions.value = res.suggestions
assistSectionId.value = sectionId
} catch {
toast.push('AI assist failed', 'error')
} finally {
assistLoading.value = false
}
}
function acceptSuggestion(text: string) {
const section = sections.value.find((s) => s.id === assistSectionId.value)
if (section) {
section.bullets.push(text)
assistSuggestions.value = assistSuggestions.value.filter((s) => s !== text)
}
}
function dismissSuggestion(text: string) {
assistSuggestions.value = assistSuggestions.value.filter((s) => s !== text)
}
async function doRenderCv() {
rendering.value = true
renderUrl.value = null
try {
const res = await api.renderCv()
renderUrl.value = res.url
toast.push('CV rendered', 'success')
} catch {
toast.push('Render failed', 'error')
} finally {
rendering.value = false
}
}
onMounted(async () => {
await Promise.all([loadProfile(), loadSections()])
loading.value = false
})
</script>
<template>
<div class="space-y-6">
<h1 class="text-2xl font-bold">CV Editor</h1>
<div v-if="loading" class="text-gray-500">Loading...</div>
<!-- Profile form -->
<section v-if="!loading" class="bg-white rounded-lg border border-gray-200 p-4 space-y-3">
<h2 class="font-semibold text-lg">Profile</h2>
<div class="grid grid-cols-2 gap-3">
<label class="block">
<span class="text-sm text-gray-600">Full name</span>
<input v-model="form.full_name" class="w-full border rounded px-2 py-1 mt-1" />
</label>
<label class="block">
<span class="text-sm text-gray-600">Email</span>
<input v-model="form.email" class="w-full border rounded px-2 py-1 mt-1" />
</label>
<label class="block">
<span class="text-sm text-gray-600">Phone</span>
<input v-model="form.phone" class="w-full border rounded px-2 py-1 mt-1" />
</label>
<label class="block">
<span class="text-sm text-gray-600">Location</span>
<input v-model="form.location" class="w-full border rounded px-2 py-1 mt-1" />
</label>
<label class="block">
<span class="text-sm text-gray-600">Headline</span>
<input v-model="form.headline" class="w-full border rounded px-2 py-1 mt-1" />
</label>
</div>
<label class="block">
<span class="text-sm text-gray-600">Summary</span>
<textarea v-model="form.summary" rows="3" class="w-full border rounded px-2 py-1 mt-1"></textarea>
</label>
<button @click="saveProfile" class="bg-indigo-600 text-white px-4 py-2 rounded text-sm">Save Profile</button>
</section>
<!-- Sections list -->
<section v-if="!loading" class="bg-white rounded-lg border border-gray-200 p-4 space-y-3">
<h2 class="font-semibold text-lg">Sections</h2>
<div v-for="section in sections" :key="section.id" class="border-b border-gray-100 pb-3 mb-3">
<div class="flex items-center justify-between">
<span class="font-medium">{{ section.title }} ({{ section.kind }})</span>
<div class="flex gap-2">
<button @click="saveSection(section)" class="text-sm text-indigo-600 hover:underline">Save</button>
<button @click="removeSection(section.id)" class="text-sm text-red-600 hover:underline">Delete</button>
</div>
</div>
<div class="text-sm text-gray-500">{{ section.org }} {{ section.location }}</div>
<ul class="list-disc ml-6 text-sm mt-1">
<li v-for="(b, i) in section.bullets" :key="i">{{ b }}</li>
</ul>
<div class="mt-2 flex gap-2 items-center">
<input
v-model="assistInstruction"
placeholder="AI assist instruction"
class="border rounded px-2 py-1 text-sm flex-1"
/>
<button
@click="callAiAssist(section.id)"
:disabled="assistLoading"
class="text-sm bg-purple-600 text-white px-3 py-1 rounded"
>
AI Assist
</button>
</div>
<div v-if="assistSectionId === section.id && assistSuggestions.length" class="mt-2 space-y-1">
<div
v-for="s in assistSuggestions"
:key="s"
class="flex items-center justify-between bg-purple-50 rounded px-2 py-1 text-sm"
>
<span>{{ s }}</span>
<span class="flex gap-2">
<button @click="acceptSuggestion(s)" class="text-green-600">Accept</button>
<button @click="dismissSuggestion(s)" class="text-gray-500">Dismiss</button>
</span>
</div>
</div>
</div>
</section>
<!-- Add section -->
<section v-if="!loading" class="bg-white rounded-lg border border-gray-200 p-4 space-y-3">
<h2 class="font-semibold text-lg">Add Section</h2>
<div class="grid grid-cols-2 gap-3">
<label class="block">
<span class="text-sm text-gray-600">Kind</span>
<select v-model="newSection.kind" class="w-full border rounded px-2 py-1 mt-1">
<option v-for="k in sectionKinds" :key="k" :value="k">{{ k }}</option>
</select>
</label>
<label class="block">
<span class="text-sm text-gray-600">Title</span>
<input v-model="newSection.title" class="w-full border rounded px-2 py-1 mt-1" />
</label>
<label class="block">
<span class="text-sm text-gray-600">Organization</span>
<input v-model="newSection.org" class="w-full border rounded px-2 py-1 mt-1" />
</label>
<label class="block">
<span class="text-sm text-gray-600">Location</span>
<input v-model="newSection.location" class="w-full border rounded px-2 py-1 mt-1" />
</label>
<label class="block">
<span class="text-sm text-gray-600">Start date</span>
<input v-model="newSection.start_date" type="date" class="w-full border rounded px-2 py-1 mt-1" />
</label>
<label class="block">
<span class="text-sm text-gray-600">End date</span>
<input v-model="newSection.end_date" type="date" class="w-full border rounded px-2 py-1 mt-1" />
</label>
</div>
<div>
<span class="text-sm text-gray-600">Bullets</span>
<ul class="ml-6 text-sm list-disc">
<li v-for="(b, i) in newSection.bullets" :key="i" class="flex items-center gap-2">
<span class="flex-1">{{ b }}</span>
<button @click="removeBullet(i)" class="text-red-600 text-xs">x</button>
</li>
</ul>
<div class="flex gap-2 mt-1">
<input v-model="newBullet" class="border rounded px-2 py-1 text-sm flex-1" @keyup.enter="addBullet" />
<button @click="addBullet" class="text-sm bg-gray-200 px-2 rounded">+</button>
</div>
</div>
<div>
<span class="text-sm text-gray-600">Tags</span>
<div class="flex flex-wrap gap-1 mt-1">
<span v-for="t in newSection.tags" :key="t" class="text-xs bg-gray-200 rounded px-2 py-0.5">{{ t }}</span>
</div>
<div class="flex gap-2 mt-1">
<input v-model="newTag" class="border rounded px-2 py-1 text-sm flex-1" @keyup.enter="addTag" />
<button @click="addTag" class="text-sm bg-gray-200 px-2 rounded">+</button>
</div>
</div>
<button @click="addSection" class="bg-indigo-600 text-white px-4 py-2 rounded text-sm">Add Section</button>
</section>
<!-- Render CV -->
<section v-if="!loading" class="bg-white rounded-lg border border-gray-200 p-4 space-y-3">
<h2 class="font-semibold text-lg">Render CV</h2>
<button @click="doRenderCv" :disabled="rendering" class="bg-indigo-600 text-white px-4 py-2 rounded text-sm">
{{ rendering ? 'Rendering...' : 'Render PDF' }}
</button>
<a v-if="renderUrl" :href="renderUrl" target="_blank" class="text-indigo-600 underline text-sm block">
Download rendered CV
</a>
</section>
</div>
</template>

View file

@ -0,0 +1,103 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useToastStore } from '@/stores/toast'
import * as api from '@/api'
import type { JobPosting } from '@/types'
const toast = useToastStore()
const postings = ref<JobPosting[]>([])
const loading = ref(true)
const newUrl = ref('')
const scoringId = ref<string | null>(null)
const scoreMap = ref<Record<string, { score: number; rationale: Record<string, unknown> }>>({})
async function loadPostings() {
try {
postings.value = await api.getPostings()
} catch {
toast.push('Failed to load postings', 'error')
} finally {
loading.value = false
}
}
async function addPosting() {
if (!newUrl.value.trim()) return
try {
const p = await api.createPosting(newUrl.value.trim())
postings.value.unshift(p)
newUrl.value = ''
toast.push('Posting added', 'success')
} catch {
toast.push('Failed to add posting', 'error')
}
}
async function scorePosting(p: JobPosting) {
scoringId.value = p.id
try {
const res = await api.scorePosting(p.id)
scoreMap.value[p.id] = { score: res.score, rationale: res.rationale }
toast.push(`Scored: ${res.score}`, 'success')
} catch {
toast.push('Scoring failed', 'error')
} finally {
scoringId.value = null
}
}
onMounted(loadPostings)
</script>
<template>
<div class="space-y-6">
<h1 class="text-2xl font-bold">Research</h1>
<section class="bg-white rounded-lg border border-gray-200 p-4 flex gap-2">
<input
v-model="newUrl"
placeholder="Paste a job URL"
class="flex-1 border rounded px-2 py-1"
@keyup.enter="addPosting"
/>
<button @click="addPosting" class="bg-indigo-600 text-white px-4 py-2 rounded text-sm">Add by URL</button>
</section>
<div v-if="loading" class="text-gray-500">Loading...</div>
<table v-if="!loading" class="w-full bg-white rounded-lg border border-gray-200 text-sm">
<thead class="bg-gray-50 text-left">
<tr>
<th class="px-3 py-2">Company</th>
<th class="px-3 py-2">Title</th>
<th class="px-3 py-2">Location</th>
<th class="px-3 py-2">Source</th>
<th class="px-3 py-2">Fetched</th>
<th class="px-3 py-2">Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="p in postings" :key="p.id" class="border-t border-gray-100">
<td class="px-3 py-2">{{ p.company }}</td>
<td class="px-3 py-2">{{ p.title }}</td>
<td class="px-3 py-2">{{ p.location }}</td>
<td class="px-3 py-2">{{ p.source }}</td>
<td class="px-3 py-2 text-gray-500">{{ p.fetched_at?.slice(0, 10) }}</td>
<td class="px-3 py-2">
<button
@click="scorePosting(p)"
:disabled="scoringId === p.id"
class="text-indigo-600 hover:underline text-sm"
>
{{ scoringId === p.id ? 'Scoring...' : 'Score' }}
</button>
<span v-if="scoreMap[p.id]" class="ml-2 text-xs bg-green-100 text-green-800 rounded px-2 py-0.5">
{{ scoreMap[p.id].score }}
</span>
</td>
</tr>
</tbody>
</table>
</div>
</template>

7
apps/web/src/vite-env.d.ts vendored Normal file
View file

@ -0,0 +1,7 @@
/// <reference types="vite/client" />
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<object, object, unknown>
export default component
}

View file

@ -0,0 +1,8 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ['./index.html', './src/**/*.{vue,ts}'],
theme: {
extend: {}
},
plugins: []
}

25
apps/web/tsconfig.json Normal file
View file

@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "preserve",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.vue"],
"references": [{ "path": "./tsconfig.node.json" }]
}

View file

@ -0,0 +1,11 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"strict": true
},
"include": ["vite.config.ts", "vitest.config.ts"]
}

12
apps/web/vite.config.ts Normal file
View file

@ -0,0 +1,12 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { fileURLToPath, URL } from 'node:url'
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))
}
}
})

17
apps/web/vitest.config.ts Normal file
View file

@ -0,0 +1,17 @@
import { defineConfig } from 'vitest/config'
import vue from '@vitejs/plugin-vue'
import { fileURLToPath, URL } from 'node:url'
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))
}
},
test: {
globals: true,
environment: 'jsdom',
include: ['src/**/*.test.ts']
}
})