Skip to content

mihaimdm22/workfully-interview

Folders and files

NameName
Last commit message
Last commit date

Latest commit

Β 

History

34 Commits
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 

Repository files navigation

Workfully Screening Bot

CI CodeQL MIT License Node 24+ pnpm 10

A conversational, FSM-driven candidate screening bot built for the Workfully technical challenge. Chat with the bot, get a structured fit verdict, share it as a public link or PDF. The whole thing lives behind a workspace shell β€” sidebar with recent screenings, dashboard at /, permanent URLs per verdict, ⌘K search.

  1. Idle β€” get a greeting and a list of commands.
  2. Screening β€” paste / upload a Job Description and a CV, watch the verdict stream in.
  3. Job Builder β€” mocked, returns to idle.

Verdicts get a permanent page at /screening/[id], an unguessable public share at /s/[slug], an Open Graph card image, and a server-rendered Chromium PDF.

A standalone architecture walkthrough lives at /walkthrough β€” single-scroll, build-time stats, inline verdict gallery using the same share components, sticky scroll-spy TOC. Reachable from the topbar's "About this project" link.

The original challenge brief lives in docs/CHALLENGE.md. The visual contract lives in DESIGN.md.


TL;DR

pnpm install
pnpm db:up               # boots Postgres in Docker
pnpm db:migrate          # applies the schema
cp .env.example .env     # set OPENROUTER_API_KEY
pnpm dev                 # http://localhost:3000

Fast path β€” try it in under a minute, no API key, only Docker:

pnpm install && pnpm db:up && pnpm db:migrate
cp .env.example .env     # OPENROUTER_API_KEY can stay as-is for fake-AI mode
WORKFULLY_FAKE_AI=1 pnpm dev

The fake AI returns deterministic verdicts driven by markers in the CV ([TEST_VERDICT_WEAK], [TEST_VERDICT_WRONG_ROLE], default = strong). Same codepath that CI runs for E2E.


What's interesting

  • FSM as the source of truth. The state machine (src/lib/fsm/machine.ts) is the only place that decides what's allowed. Server actions translate user input into events; nothing else mutates state. This makes the chat resumable, the transitions exhaustively testable, and /cancel work the same way from any sub-state.
  • Pure FSM, request-scoped actor. The screening AI call is modelled as an XState actor that's provided per request, so the machine itself stays pure (no AI imports). Tests use a fake actor that returns a fixture; production wires up the real LLM call (Claude via OpenRouter). Same machine, two contexts.
  • Structured output, not string parsing. The screening verdict is generated via generateObject (or streamObject for streaming) against a Zod schema (src/lib/domain/screening.ts). If the model can't produce schema-valid JSON, the SDK retries automatically and the actor either succeeds with a typed object or throws to the FSM's error path. We never parse free-text.
  • Streaming verdicts via SSE. screenStreaming() wraps streamObject and emits partial verdicts through an onPartial callback. The orchestrator's dispatchStreaming() threads it through, the route handler at /api/screening/stream returns text/event-stream, and <ChatStream> consumes it client-side. Fake-AI mode emits 11 timed partials over ~2.5s so demos show the same shape without burning OpenRouter credits.
  • Persisted snapshots = resumable conversations. Every transition writes the XState PersistedSnapshot to Postgres. A page reload rehydrates the actor at exactly the state it left off in.
  • Public share by design, private by default. A verdict gets a permanent /screening/[id] page (private) and an opt-in /s/[slug] page (public). The privacy boundary is enforced by TypeScript: the public path uses getScreeningForShare which returns a narrower type that omits JD, CV, and the conversation log β€” no accidental leak through a careless render.
  • One FSM-shaped E2E test, the AI is faked. WORKFULLY_FAKE_AI=1 swaps the real screen() call for a deterministic stub. CI runs Playwright against this. Real model behavior is covered by the schema-validated unit tests on the screening service boundary.
  • Persistent sidebar history across "+ New screening". The button used to clear the conversation cookie, which made the proxy mint a fresh conversations row and hide every prior verdict from the sidebar query. Fix: keep the cookie, delete the current chat transcript, dispatch FSM RESET. The parent conversation row and its screenings rows survive, so the sidebar shows every verdict produced from this browser. A <NewScreeningButton/> client wrapper reads body.dataset.streaming (published by <ChatStream>) and pops a window.confirm() if you click mid-evaluation, then useFormStatus().pending collapses fast double-clicks to one round-trip. A one-shot <HistoryToast/> (gated by localStorage) explains the new behavior on first reset, then strips ?reset=1 via router.replace.
  • Runtime AI knobs without redeploying. A topbar gear opens a settings modal β€” pick from a server-side allowlist of OpenRouter models, tune the FSM evaluation timeout, max retries, and temperature. The model dropdown live-fetches OpenRouter's /api/v1/models and intersects the response with a curated allowlist (so every option is one we trust for structured output and OpenRouter is currently serving), with a 1h in-process cache and a hardcoded fallback. Settings are persisted in a singleton app_settings Postgres row. Precedence at request time is OPENROUTER_MODEL env var β†’ DB β†’ hardcoded default β€” env still wins, preserving the ops-driven swap promise from ADR 0004. The FSM evaluation timeout is now a per-actor delays.evalTimeout reading from context, so settings changes apply on the next dispatch.

Stack

Layer Choice ADR
Runtime Next.js 16 (App Router) + React 19 0002
FSM XState v5 0001
Database Postgres 17 + Drizzle ORM 0003
Concurrency Optimistic CAS (per-conversation version column) 0006
AI Vercel AI SDK v6 + Claude Sonnet 4.6 via OpenRouter 0004
Streaming streamObject + SSE route handler (/api/screening/stream) 0004
Validation Zod v4 (single source of truth: schema β†’ types β†’ JSON schema for the LLM) β€”
Testing Vitest (unit) + Testcontainers (integration) + Playwright (E2E, fake AI) 0005
Style Tailwind CSS 4 (@theme inline tokens, utility-class refactor) DESIGN.md
PDF export puppeteer-core + @sparticuz/chromium (server-side render) β€”
OG image next/og at /s/[slug]/opengraph-image β€”
Lang TypeScript 6 strict + noUncheckedIndexedAccess β€”

All versions are the latest stable as of April 2026.


State machine

                 β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
                 β”‚           idle               β”‚ ◀──── /cancel, /reset
                 β”‚   "I'm here to help."        β”‚
                 β””β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”˜
   /screen β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜                   └────────── /newjob
                    β–Ό                              β–Ό
       β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”    β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
       β”‚       screening         β”‚    β”‚    jobBuilder       β”‚
       β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”β”‚    β”‚   (mocked, /cancel β”‚
       β”‚ β”‚ gathering            β”‚β”‚    β”‚    returns to idle)β”‚
       β”‚ β”‚  fills JD/CV slots,  β”‚β”‚    β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
       β”‚ β”‚  always β†’ evaluating β”‚β”‚
       β”‚ β”‚  once both filled    β”‚β”‚
       β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜β”‚
       β”‚            β–Ό             β”‚   ──invokes screen() actor──▢ OpenRouter
       β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”β”‚   ◀──── result | error | abort ──
       β”‚ β”‚ evaluating           β”‚β”‚
       β”‚ β”‚  β”” after 60s ───┐    β”‚β”‚
       β”‚ β””β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”‚β”€β”€β”€β”€β”˜β”‚
       β”‚      β–Ό       β–Ό    β–Ό     β”‚
       β”‚ presenting  error timedOut β†’ idle (with typed error)
       β”‚   Result    β†’ idle      β”‚
       β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

The full hierarchy and transition table lives in src/lib/fsm/machine.ts and the E2E behavior in src/lib/fsm/machine.test.ts.


Project structure

src/
β”œβ”€β”€ app/                            # Next.js App Router
β”‚   β”œβ”€β”€ actions.ts                  # Server Actions β€” the only mutation surface
β”‚   β”œβ”€β”€ layout.tsx                  # Root: theme bootstrap, font loading
β”‚   β”œβ”€β”€ globals.css                 # Tailwind 4 @theme tokens, design vars
β”‚   β”œβ”€β”€ (workspace)/                # Workspace shell route group (sidebar + topbar)
β”‚   β”‚   β”œβ”€β”€ layout.tsx              # Shell β€” Sidebar + Topbar + CmdKPalette
β”‚   β”‚   β”œβ”€β”€ page.tsx                # Dashboard (`/`) β€” screening cards + filter tabs
β”‚   β”‚   └── screening/
β”‚   β”‚       β”œβ”€β”€ new/page.tsx        # Active chat (`/screening/new`)
β”‚   β”‚       └── [id]/page.tsx       # Permanent verdict page (`/screening/[id]`)
β”‚   β”œβ”€β”€ s/                          # Public share (no shell)
β”‚   β”‚   β”œβ”€β”€ layout.tsx              # Bare layout β€” no sidebar leak
β”‚   β”‚   └── [slug]/
β”‚   β”‚       β”œβ”€β”€ page.tsx            # Public verdict (`/s/[slug]`)
β”‚   β”‚       β”œβ”€β”€ opengraph-image.tsx # 1200Γ—630 OG card via next/og
β”‚   β”‚       └── pdf/
β”‚   β”‚           β”œβ”€β”€ page.tsx        # Print HTML (used by Chromium)
β”‚   β”‚           └── download/route.ts  # Headless Chromium β†’ A4 PDF
β”‚   └── api/screening/stream/route.ts  # SSE β€” streaming verdicts
β”œβ”€β”€ components/                     # UI
β”‚   β”œβ”€β”€ shell/                      # Workspace chrome
β”‚   β”‚   β”œβ”€β”€ sidebar.tsx             # Brand + recents + workspace footer
β”‚   β”‚   β”œβ”€β”€ new-screening-button.tsx # Client wrapper: mid-eval confirm + pending guard
β”‚   β”‚   └── topbar.tsx              # Breadcrumbs + ⌘K input + theme toggle
β”‚   β”œβ”€β”€ ui/                         # Primitives (Pill, IconButton, ScoreDisplay)
β”‚   β”œβ”€β”€ chat-stream.tsx             # client: SSE consumer, optimistic render, publishes body.dataset.streaming
β”‚   β”œβ”€β”€ history-toast.tsx           # One-shot toast on `?reset=1` after + New screening
β”‚   β”œβ”€β”€ streaming-verdict.tsx       # Progressive verdict reveal during stream
β”‚   β”œβ”€β”€ verdict-header.tsx          # Detail-page header β€” score + meta
β”‚   β”œβ”€β”€ requirement-list.tsx        # Must-haves + nice-to-haves
β”‚   β”œβ”€β”€ bullet-block.tsx            # Strengths / gaps two-column
β”‚   β”œβ”€β”€ recommendation.tsx          # Slack-paste block + copy button
β”‚   β”œβ”€β”€ share-row.tsx               # "Generate share link"
β”‚   β”œβ”€β”€ screening-card.tsx          # Dashboard card
β”‚   β”œβ”€β”€ cmd-k-palette.tsx           # ⌘K search palette
β”‚   β”œβ”€β”€ theme-toggle.tsx            # Light/dark with localStorage
β”‚   β”œβ”€β”€ message-bubble.tsx
β”‚   β”œβ”€β”€ screening-result-card.tsx   # In-chat verdict card
β”‚   └── state-pill.tsx
└── lib/
    β”œβ”€β”€ domain/                     # Pure types & rules β€” no I/O
    β”‚   β”œβ”€β”€ intent.ts               # text β†’ FSM event classifier
    β”‚   β”œβ”€β”€ screening.ts            # Zod schema for the verdict
    β”‚   β”œβ”€β”€ verdict-style.ts        # Single source of truth for verdict colors
    β”‚   └── fuzzy-match.ts          # 30-line scorer powering ⌘K
    β”œβ”€β”€ fsm/                        # XState machine + orchestrator
    β”‚   β”œβ”€β”€ machine.ts              # The state machine itself (pure)
    β”‚   β”œβ”€β”€ orchestrator.ts         # FSM ↔ DB ↔ AI bridge (server-only)
    β”‚   β”œβ”€β”€ replies.ts              # state β†’ bot prompt
    β”‚   β”œβ”€β”€ pair-screenings.ts      # Pairs verdicts to messages in O(N+M)
    β”‚   └── snapshot.ts             # zod-validated PersistedSnapshot
    β”œβ”€β”€ db/                         # Drizzle layer
    β”‚   β”œβ”€β”€ schema.ts               # conversations, messages, screenings, share_links
    β”‚   β”œβ”€β”€ client.ts               # Lazy singleton, dev-HMR safe
    β”‚   β”œβ”€β”€ repositories.ts         # Public/private split: getScreeningById vs
    β”‚   β”‚                           #   getScreeningForShare (narrower type)
    β”‚   β”œβ”€β”€ connection-string.ts    # DATABASE_URL fallback chain
    β”‚   └── migrations/             # Generated by drizzle-kit
    β”œβ”€β”€ ai/                         # AI boundary
    β”‚   β”œβ”€β”€ screen.ts               # generateObject + streamObject + Zod
    β”‚   └── extract-pdf.ts          # PDF β†’ text via unpdf
    β”œβ”€β”€ pdf/
    β”‚   └── browser.ts              # puppeteer-core + @sparticuz/chromium launcher
    β”œβ”€β”€ log.ts                      # Request-scoped structured logger
    └── cookies.ts                  # Conversation cookie helpers
e2e/
└── screening.spec.ts               # Playwright β€” dashboard + new chat + share round-trip
fixtures/                           # Sample JD + 3 CVs (strong / weak / wrong-role)
docs/
β”œβ”€β”€ CHALLENGE.md                    # The original brief
└── adr/                            # Architecture Decision Records (0001–0006)
DESIGN.md                           # Design system β€” tokens, components, a11y

The lib/ layout follows a strict dependency order:

domain   ◀──  fsm   ◀──  ai
   β–²           β–²          β–²
   β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
              app, components

domain has no dependencies on anything else. fsm depends on domain. The orchestrator is the only file that reaches across layers (FSM + DB + AI), and it's server-only by design.


Testing strategy

The README's grading criteria explicitly name testing strategy, so here it is in plain terms:

Layer Tool What it proves
Pure domain Vitest Intent classifier, replies, schema validation
FSM Vitest + XState Every transition, /cancel from each substate, the error branch, snapshot rehydration
AI service Vitest + MockLanguageModelV3 Schema validation, error propagation, latency tracking
Repositories (Postgres integration) Not implemented β€” would use Testcontainers in CI; the pattern is here
End-to-end Playwright + WORKFULLY_FAKE_AI=1 UI wiring, FSM transitions visible to the user

All unit tests green; coverage thresholds enforced (β‰₯80% statements/functions/lines, β‰₯75% branches; current ~95%). One drift test (verdict-style.test.ts) reads globals.css at runtime and asserts every CSS variable matches the constants in verdict-style.ts β€” the build fails if DESIGN.md and the runtime tokens diverge.

pnpm test          # Vitest, ~200 ms
pnpm test:coverage # ditto + v8 coverage with thresholds
pnpm test:e2e      # Playwright (needs DB + WORKFULLY_FAKE_AI=1)
pnpm check         # format:check + lint + typecheck + knip + test

See docs/adr/0005-testing-strategy.md for the full rationale on why tests are split that way.


Repo tooling

Everything below runs in CI on every push and PR.

Concern Tool
Formatter Prettier 3 + prettier-plugin-tailwindcss (sorts Tailwind classes)
Linter ESLint 9 (eslint-config-next core-web-vitals + typescript)
Type checker TypeScript 6 strict + noUncheckedIndexedAccess
Dead-code scanner Knip (unused files / exports / deps)
Pre-commit hook Husky 9 β†’ lint-staged (Prettier + ESLint --fix --max-warnings 0)
Commit-msg hook Husky 9 β†’ commitlint (Conventional Commits, custom scope-enum)
Security scanning GitHub CodeQL (security-and-quality queries) + pnpm audit --prod
Workflow linter actionlint (Docker, lints .github/workflows/**)
Stale PRs / issues actions/stale@v10 (30/7 day window)
Dependency upgrades Dependabot (npm + github-actions, weekly, minor/patch grouped)
Coverage Vitest + @vitest/coverage-v8, hard floors enforced
Editor consistency .editorconfig, .nvmrc (Node 24)

Conventional Commits are enforced both locally (commit-msg hook) and in CI:

feat(fsm): add presentingResult substate
fix(ai): handle schema-violation error from generateObject
docs(adr): add ADR 0006 explaining streaming verdicts
chore(deps): bump xstate to 5.32.0

Allowed scopes: fsm, ai, db, ui, domain, e2e, ci, deps, docs, config, orchestrator, proxy, actions, log.


Try it locally

  1. pnpm install
  2. pnpm db:up && pnpm db:migrate
  3. Copy .env.example to .env and add your OPENROUTER_API_KEY.
  4. pnpm dev
  5. Open http://localhost:3000 β€” you land on the dashboard. Empty on first run.
  6. Click + New screening in the sidebar (or hit ⌘K and pick "New screening") to land on /screening/new. Type /screen, paste a JD (try fixtures/job-description.pdf), then a CV (cv-strong-match.pdf for a high-confidence verdict, or cv-wrong-role.pdf for a wrong-role rejection).
  7. Watch the verdict stream in. When it lands, you redirect to a permanent /screening/[id] page. Click Generate share link to mint a public /s/[slug] URL β€” paste it into Slack to see the OG card unfurl, or click the PDF icon on the share page to download an A4 print.
  8. Back on the dashboard, your screening appears as a card. Use the tab strip to filter by verdict tier.

Other commands the bot understands: /newjob, /cancel, /reset, or natural-language equivalents (screen a candidate, start over, stop). ⌘K (or Ctrl-K) opens the search palette from anywhere.


What shipped on day two

After the original v0.1.0 deliverable, an audit pass on the codebase produced 14 small wins plus one structural change. The headline:

Concurrency-safe orchestrator with FSM-owned timeout and structural cancellation. Two reviewers (Codex CLI and an independent Claude subagent) audited src/lib/fsm/orchestrator.ts and surfaced the same bug: under concurrent requests on the same conversation, two writers could both read the same FSM snapshot, both run actor.send, both write β€” last-writer-wins silently fork the state machine. They also both flagged that the 60s EVAL_TIMEOUT_MS lived outside the FSM, which leaked an in-flight generateObject HTTP call when it fired and created a fork window where the FSM could land in presentingResult while the orchestrator returned a "took too long" error.

First instinct was SELECT FOR UPDATE to serialise the writes. Rejected: the lock would be held across the 10s+ AI call and convoy the connection pool under load (Vercel Fluid Compute runs max: 1 per instance β€” one stuck conversation pins the whole instance for up to 60s).

Shipped instead:

  • Optimistic concurrency via a version column on conversations. The orchestrator reads (snapshot, version), runs the AI call with no DB connection held, then commits via UPDATE ... WHERE id = ? AND version = ?. CAS conflict throws ConcurrentModificationError; the action layer maps it to a typed product string ("This conversation changed in another tab β€” refresh to continue.").
  • FSM-owned timeout via XState's after delayed transition. The orchestrator no longer races with the FSM β€” when timeout fires, XState cleanly transitions to idle and stops the invoked actor.
  • Structural cancellation. XState's fromPromise signal is forwarded into screen(input, { signal }) and on into generateObject({ abortSignal }). When the FSM exits evaluating for any reason, the in-flight HTTP call to OpenRouter is actually aborted, not just abandoned.
  • Real-Postgres integration test. One Testcontainers test in test/integration/orchestrator.concurrent.test.ts verifies the SQL primitive serialises concurrent CAS attempts. Lives in a separate Vitest lane (pnpm test:integration) so the default pnpm test stays Docker-free.

Full rationale in ADR 0006.

The audit also produced 13 small wins around correctness, observability, and DX: actor.stop() in try/finally on every error path, secure cookie flag in production, Zod schema tightening so a malformed snapshot row can't crash XState during rehydration, request-scoped structured logger that emits FSM transitions and AI latency, unit tests for the proxy and Server Actions surfaces, Codecov upload, CI gating fix so e2e waits for unit tests to pass, Postgres connection-pool size that branches on process.env.VERCEL, and explicit fixture markers for the fake-AI test escape hatch.

What shipped on day three

After the audit shipped, the chat at / started feeling like a tech demo, not a product. Day three was a platform redesign β€” turn the single-page chat into a workspace.

The routing model changed. / is now the dashboard. The active chat lives at /screening/new. Every verdict gets a permanent URL at /screening/[id]. Public shares get an unguessable slug at /s/[slug] with its own bare layout (no sidebar leak), an Open Graph card at /s/[slug]/opengraph-image, and a server-rendered A4 PDF at /s/[slug]/pdf/download (headless Chromium via puppeteer-core + @sparticuz/chromium).

The verdict streams now. screenStreaming() wraps streamObject and emits partials through an onPartial callback. dispatchStreaming() threads it through the orchestrator. /api/screening/stream is an SSE route that emits user-message, partial, done, and error events. <ChatStream> consumes the SSE response, optimistically renders the user message, progressively renders the verdict via <StreamingVerdict>, and redirects to /screening/[id] when the stream closes. Fake-AI mode (WORKFULLY_FAKE_AI=1) emits 11 timed partials over ~2.5s so demos show the streaming shape without burning OpenRouter credits.

TypeScript-enforced privacy boundary. A new share_links table (128-bit unguessable slug, ON DELETE CASCADE, UNIQUE on screening_id) backs the public share. The repository layer splits into getScreeningById (private β€” returns JD + CV + transcript) and getScreeningForShare (public β€” returns a narrower type that omits all three). The public page literally cannot render JD or CV β€” TypeScript fails the build if you try.

Design system as a living document. New DESIGN.md at the repo root captures tokens, component contracts, responsive breakpoints, a11y rules, motion, and dark-mode behavior. New src/lib/domain/verdict-style.ts is the single source of truth for verdict color mappings β€” used by the dashboard, sidebar dot, screening header, public share, OG card, and PDF page. A drift test reads globals.css and asserts every CSS variable matches the constants. CI fails if they diverge.

Tailwind 4 utility-first refactor. Every component (~20 files) migrated from inline style={{ ... }} to Tailwind utility classes generated from @theme inline tokens. New utilities: w-sidebar, h-header, shadow-pop, animate-fade-in, animate-scale-in. Net: zero remaining inline styles outside the @vercel/og image route (which requires inline by design β€” next/og doesn't run Tailwind).

Other day-three bits. ⌘K palette with a hand-rolled fuzzy-match scorer (30 lines beats a 9 KB library at this scale). ThemeToggle with a tiny inline <head> script that sets data-theme before React hydrates β€” no flash of wrong theme on first paint. candidateName and role extracted by the model on the same AI call, so dashboard cards show real names instead of "Untitled screening". End-to-end coverage updated for the new routing: dashboard listing, public share access without sidebar, share-link round-trip.

What I'd do with another day

  • Uploaded-PDF storage. Right now we extract text from JD/CV PDFs and discard the original bytes. If recruiters need to re-download what the candidate uploaded, push the bytes to S3 and record a key on the screening row. The verdict PDF (which the share page generates) is rendered on demand from data β€” no storage needed.
  • Screening replays. The screenings table already stores JD + CV verbatim, so re-running an old verdict against a different model is just a worker job away.
  • Multi-tenancy. Every row would gain a workspace_id; cookies would carry it. Routes would scope queries on it. Pure mechanical work, omitted for time.
  • Per-conversation rate limiting. An in-flight token (Redis or in-memory if single-instance) would deflect concurrent dispatch attempts before they hit the DB at all. The CAS pattern handles correctness; this would handle abuse / accidental double-clicks gracefully.
  • Broader repository integration tests. W19' shipped one Testcontainers case for the CAS primitive; the rest of the repo layer would benefit from the same treatment now that the infrastructure is in place.
  • Eval harness. pnpm eval against a labeled fixture set, run on every prompt change. That's how you measure verdict quality without unit-testing the model.
  • Job Builder for real. Today it returns a mock prompt β€” implementing it with the same FSM-as-source-of-truth approach would mirror the screening flow.

License

MIT (this is an interview challenge response β€” feel free to reuse anything useful).

About

Resources

License

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

No releases published

Packages

 
 
 

Contributors