diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml
index d18ca3c..9f70b2e 100644
--- a/.forgejo/workflows/ci.yml
+++ b/.forgejo/workflows/ci.yml
@@ -23,6 +23,9 @@ jobs:
python-version: '3.12'
cache: pip
+ - name: Install system dependencies
+ run: sudo apt-get update -q && sudo apt-get install -y libsqlcipher-dev
+
- name: Install dependencies
run: pip install -r requirements.txt
diff --git a/web/src/components/AppNav.vue b/web/src/components/AppNav.vue
index 59f7265..7668e9b 100644
--- a/web/src/components/AppNav.vue
+++ b/web/src/components/AppNav.vue
@@ -59,9 +59,6 @@
-
@@ -134,23 +131,6 @@ function exitHackerMode() {
restoreTheme()
}
-const _apiBase = import.meta.env.BASE_URL.replace(/\/$/, '')
-
-async function switchToClassic() {
- // Persist preference via API so Streamlit reads streamlit from user.yaml
- // and won't re-set the cookie back to vue (avoids the ?prgn_switch rerun cycle)
- try {
- await fetch(_apiBase + '/api/settings/ui-preference', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ preference: 'streamlit' }),
- })
- } catch { /* non-fatal — cookie below is enough for immediate redirect */ }
- document.cookie = 'prgn_ui=streamlit; path=/; SameSite=Lax'
- // Navigate to root (no query params) — Caddy routes to Streamlit based on cookie
- window.location.href = window.location.origin + '/'
-}
-
const navLinks = computed(() => [
{ to: '/', icon: HomeIcon, label: 'Home' },
{ to: '/review', icon: ClipboardDocumentListIcon, label: 'Job Review' },
@@ -321,29 +301,6 @@ const mobileLinks = [
margin: 0;
}
-.sidebar__classic-btn {
- display: flex;
- align-items: center;
- width: 100%;
- padding: var(--space-2) var(--space-3);
- margin-top: var(--space-1);
- background: none;
- border: none;
- border-radius: var(--radius-md);
- color: var(--color-text-muted);
- font-size: var(--text-xs);
- font-weight: 500;
- cursor: pointer;
- opacity: 0.6;
- transition: opacity 150ms, background 150ms;
- white-space: nowrap;
-}
-
-.sidebar__classic-btn:hover {
- opacity: 1;
- background: var(--color-surface-alt);
-}
-
/* ── Theme picker ───────────────────────────────────── */
.sidebar__theme {
padding: var(--space-2) var(--space-3);
diff --git a/web/src/stores/prep.test.ts b/web/src/stores/prep.test.ts
index 6ac6840..3afbd54 100644
--- a/web/src/stores/prep.test.ts
+++ b/web/src/stores/prep.test.ts
@@ -27,6 +27,7 @@ describe('usePrepStore', () => {
tech_brief: null, funding_brief: null, red_flags: null, accessibility_brief: null,
generated_at: '2026-03-20T12:00:00' }, error: null }) // research
.mockResolvedValueOnce({ data: [], error: null }) // contacts
+ .mockResolvedValueOnce({ data: [], error: null }) // qa
.mockResolvedValueOnce({ data: { status: 'none', stage: null, message: null }, error: null }) // task
.mockResolvedValueOnce({ data: { id: 1, title: 'Engineer', company: 'Acme', url: null,
description: 'Build things.', cover_letter: null, match_score: 80,
@@ -50,6 +51,7 @@ describe('usePrepStore', () => {
tech_brief: null, funding_brief: null, red_flags: null, accessibility_brief: null,
generated_at: null }, error: null })
.mockResolvedValueOnce({ data: [], error: null })
+ .mockResolvedValueOnce({ data: [], error: null }) // qa
.mockResolvedValueOnce({ data: { status: 'none', stage: null, message: null }, error: null })
.mockResolvedValueOnce({ data: { id: 1, title: 'Old Job', company: 'OldCo', url: null,
description: null, cover_letter: null, match_score: null, keyword_gaps: null }, error: null })
@@ -62,6 +64,7 @@ describe('usePrepStore', () => {
mockApiFetch
.mockResolvedValueOnce({ data: null, error: { kind: 'http', status: 404, detail: '' } }) // 404 → null
.mockResolvedValueOnce({ data: [], error: null })
+ .mockResolvedValueOnce({ data: [], error: null }) // qa
.mockResolvedValueOnce({ data: { status: 'none', stage: null, message: null }, error: null })
.mockResolvedValueOnce({ data: { id: 2, title: 'New Job', company: 'NewCo', url: null,
description: null, cover_letter: null, match_score: null, keyword_gaps: null }, error: null })
@@ -102,6 +105,7 @@ describe('usePrepStore', () => {
tech_brief: null, funding_brief: null, red_flags: null, accessibility_brief: null,
generated_at: null }, error: null })
.mockResolvedValueOnce({ data: [], error: null })
+ .mockResolvedValueOnce({ data: [], error: null }) // qa
.mockResolvedValueOnce({ data: { status: 'none', stage: null, message: null }, error: null })
.mockResolvedValueOnce({ data: { id: 1, title: 'Eng', company: 'Acme', url: null,
description: null, cover_letter: null, match_score: null, keyword_gaps: null }, error: null })
@@ -112,11 +116,12 @@ describe('usePrepStore', () => {
// Mock first poll → completed
mockApiFetch
.mockResolvedValueOnce({ data: { status: 'completed', stage: null, message: null }, error: null })
- // re-fetch on completed: research, contacts, task, fullJob
+ // re-fetch on completed: research, contacts, qa, task, fullJob
.mockResolvedValueOnce({ data: { company_brief: 'Updated!', ceo_brief: null, talking_points: null,
tech_brief: null, funding_brief: null, red_flags: null, accessibility_brief: null,
generated_at: '2026-03-20T13:00:00' }, error: null })
.mockResolvedValueOnce({ data: [], error: null })
+ .mockResolvedValueOnce({ data: [], error: null }) // qa
.mockResolvedValueOnce({ data: { status: 'completed', stage: null, message: null }, error: null })
.mockResolvedValueOnce({ data: { id: 1, title: 'Eng', company: 'Acme', url: null,
description: 'Now with content', cover_letter: null, match_score: null, keyword_gaps: null }, error: null })
@@ -134,6 +139,7 @@ describe('usePrepStore', () => {
tech_brief: null, funding_brief: null, red_flags: null, accessibility_brief: null,
generated_at: null }, error: null })
.mockResolvedValueOnce({ data: [], error: null })
+ .mockResolvedValueOnce({ data: [], error: null }) // qa
.mockResolvedValueOnce({ data: { status: 'none', stage: null, message: null }, error: null })
.mockResolvedValueOnce({ data: { id: 1, title: 'Eng', company: 'Acme', url: null,
description: null, cover_letter: null, match_score: null, keyword_gaps: null }, error: null })
@@ -162,6 +168,7 @@ describe('usePrepStore', () => {
tech_brief: null, funding_brief: null, red_flags: null, accessibility_brief: null,
generated_at: '2026-03-20T12:00:00' }, error: null }) // research OK
.mockResolvedValueOnce({ data: null, error: { kind: 'http', status: 500, detail: 'DB error' } }) // contacts fail
+ .mockResolvedValueOnce({ data: [], error: null }) // qa
.mockResolvedValueOnce({ data: { status: 'none', stage: null, message: null }, error: null }) // task OK
.mockResolvedValueOnce({ data: { id: 1, title: 'Engineer', company: 'Acme', url: null,
description: 'Build things.', cover_letter: null, match_score: 80,
diff --git a/web/src/stores/survey.test.ts b/web/src/stores/survey.test.ts
index 6997256..493c07b 100644
--- a/web/src/stores/survey.test.ts
+++ b/web/src/stores/survey.test.ts
@@ -54,14 +54,20 @@ describe('useSurveyStore', () => {
})
it('analyze stores result including mode and rawInput', async () => {
+ vi.useFakeTimers()
const mockApiFetch = vi.mocked(useApiFetch)
+ // POST → task accepted
+ mockApiFetch.mockResolvedValueOnce({ data: { task_id: 7, is_new: true }, error: null })
+ // Poll → completed with result
mockApiFetch.mockResolvedValueOnce({
- data: { output: '1. B — reason', source: 'text_paste' },
+ data: { status: 'completed', stage: null, message: null,
+ result: { output: '1. B — reason', source: 'text_paste' } },
error: null,
})
const store = useSurveyStore()
await store.analyze(1, { text: 'Q1: test', mode: 'quick' })
+ await vi.advanceTimersByTimeAsync(3000)
expect(store.analysis).not.toBeNull()
expect(store.analysis!.output).toBe('1. B — reason')
@@ -69,6 +75,7 @@ describe('useSurveyStore', () => {
expect(store.analysis!.mode).toBe('quick')
expect(store.analysis!.rawInput).toBe('Q1: test')
expect(store.loading).toBe(false)
+ vi.useRealTimers()
})
it('analyze sets error on failure', async () => {