This Portfolio
Most developer portfolios show that you can build interfaces. This one had to show how I think — and also work as a practical tool for recruiters to understand my work and reach me.
This site itself: a Next.js portfolio built in three phases, with an AI meeting-request assistant on the Contact page that reads a recruiter's preferred time and offers real open slots, without ever letting the model invent one.
What I built
- Designed the Explore/Recruiter mode split so the 3D universe is additive, never required, to reach any content.
- Built the meeting-request assistant: Claude extracts constraints, a deterministic engine generates and re-validates every slot.
- Added cost and abuse controls (a profanity gate, Haiku triage, a two-strike cutoff) before the endpoint went live, not after.
- Wrote every project's case study around the same reasoning structure: problem, options considered, decision, and what I learned.
Key decision
I built it in three deliberate phases: make it work, make it memorable, make it demonstrate how I think. At every point there was a shippable, recruiter-usable site, and the universe/3D layer was additive on top of it, never a gate in front of it. Concretely: Next.js with content validated by Zod at build time (a broken case study fails `next build`, not the live site), an explicit low-animation Recruiter Mode next to the 3D Explore Mode, a scheduling assistant built on a model-extracts / engine-decides split rather than a bare chatbot wired to an API key, and real cost and abuse controls on every public AI endpoint before it shipped, not after someone found the gap.
What this project taught me
- The pattern that made every AI feature on this site trustworthy is the same one, twice: the model interprets, and a small deterministic function decides. That split matters more once a feature is public and someone will eventually try to break it.
- Shipping in three phases (a plain, recruiter-ready site first, then the interactive layer, then the reasoning and the AI feature) meant the site was never in a half-finished state if someone found it early. Each phase had to stand on its own.
- A public endpoint isn't done when it works for a good-faith visitor. The off-topic cutoff and the DNS email check both came from watching a handful of adversarial test messages get through. That's the traffic to assume before shipping, not after.
- The domain, DNS, deployment protection, and email delivery are their own small system with their own failure modes, separate from code that ran fine on localhost. Testing has to include that system, not stop at 'it works on my machine.'
The full reasoning
The problem, options, and constraints
Problem
Most developer portfolios are the same shape: a hero section, a grid of project cards, a contact form that goes into a void. They prove you can assemble a page, not that you can notice a real problem, weigh options, and defend a decision. As a CS student applying for internships and new-grad roles, a portfolio that only listed technologies wasn't going to be the thing that got me a callback.
Observation
Every project I'd built already had the interesting part (the reasoning, the constraints, the thing that broke and how I fixed it), living only in my head or in commit messages nobody would read. The portfolio itself was also a chance to demonstrate that reasoning live, not just describe it: a recruiter using an AI scheduling assistant to book time with me is a stronger proof than a paragraph claiming I can build one.
The question
Could the portfolio be a piece of software that shows how I think (problem, reasoning, decision, result) while still working, in under a minute, for a recruiter who just wants my resume and a way to reach me?
User need
A recruiter has about a minute and needs to verify: can this person build real things, do they communicate clearly, and is it easy to actually talk to them. They don't want to hunt for a resume link, and they don't want to send an email into a void and wait days for a reply.
Constraints
- Solo build, done in phases alongside full-time coursework and two part-time IT jobs
- Must never sacrifice usability for visual flair: a recruiter who can't find the resume link loses more than a missing animation costs
- No dedicated backend budget: Vercel's free tier plus free tiers of Upstash and Resend
- Any public AI feature is a spend and abuse surface the moment it ships, not a hypothetical one
- Real personal data at stake (my own email and availability), so the scheduler couldn't just trust whatever the client sent
Options considered
| Option | Trade-off |
|---|---|
| A standard single-page portfolio: hero, project grid, contact form | Fast to build and instantly familiar to a recruiter, but indistinguishable from thousands of others and says nothing about how I think |
| A fully 3D, WebGL-first site: everything happens inside the canvas | Memorable, but risks being unusable on low-end phones, bad for SEO and accessibility, and lets the visual layer overshadow the actual content |
| A conventional site by default, with a 3D layer as an additive, skippable experience | More to build (two coordinated presentations of the same content), but the content never depends on the 3D layer working |
Architecture
Root layout
ModeProvider (localStorage + prefers-reduced-motion default) picks Explore vs Recruiter
Explore: 3D universe (React Three Fiber, code-split, ssr:false) on desktop;
simplified vertical Sun + planet-cards layout on mobile
Recruiter: plain fast layout, identical content, no 3D at all
Content: src/content/*.ts --Zod--> parsed once at module load
a malformed project / experience / skill entry fails the build, not the page
Contact -> AI scheduling assistant
chat message -> POST /api/schedule
profanity regex -> cheap Haiku triage -> off-topic: warn/close, Sonnet never spent
on-topic -> Sonnet (Haiku once already warned) extracts constraints only
deterministic engine (lib/scheduling/slots.ts) generates every real slot from
src/content/scheduling.ts and filters by those constraints; the model never
states or invents a specific time
pick a slot -> POST /api/schedule/confirm
re-derives the exact slot (rejects stale/tampered), verifies the email
domain resolves (MX / A record, blocks disposable domains), notifies me by
email (Resend), returns Google Calendar + .ics links to the visitorThings that didn't work immediately
A public AI endpoint is an open cost and abuse surface the moment it's live. Anyone can send it anything, repeatedly.
- Initial approach
- One Claude call per message, with a generic per-IP rate limit as the only backstop.
- Problem with it
- Every stray, off-topic, or hostile message costs the same as a real booking conversation. A rate limit caps the worst case eventually, but doesn't stop someone from running up real spend well before hitting it.
- Decision
- Classify before spending, and never trust the client's account of the conversation.
- Final solution
- A blunt profanity regex and a cheap Haiku triage pass gate every message before the full Sonnet call ever runs. Two off-topic warnings (counted by scanning the transcript for a fixed marker string the server itself writes, not a client-supplied counter) close the conversation server-side and stop calling any model at all.
- Result
- A bad-faith session now costs a small fraction of what a real conversation costs, and can't be talked past by editing the message history sent from the browser.
The scheduler has to guarantee a shown time is genuinely free, with no real calendar backend behind it.
- Initial approach
- Let the model read the conversation and also state which specific times were open.
- Problem with it
- A model has no ground truth for 'is this actually free'. A slightly-wrong or fully hallucinated time looks identical to a real one in the reply.
- Decision
- Split the responsibility: the model interprets, a deterministic function decides.
- Final solution
- The model only extracts constraints: preferred day, time of day, duration. A plain TypeScript function generates every real slot from a config file of my actual availability and re-validates the exact slot (start and end) chosen at confirm time.
- Result
- Every slot ever shown is provably inside my stated availability and lead time, and a stale, tampered, or duration-swapped confirm request is rejected server-side, never just trusted.
A confirmed booking with a mistyped email silently wastes everyone's time.
- Initial approach
- A regex format check on the email, the same as most forms do.
- Problem with it
- 'jane@gmial.con' passes a format check and fails completely. The booking looks confirmed but no one can ever be reached.
- Decision
- Verify the domain can actually receive mail, not just that the string looks like one.
- Final solution
- Confirm runs a DNS MX lookup on the domain (falling back to an A record for domains without an explicit MX) and rejects known disposable/test domains outright; a transient DNS error doesn't block a real visitor.
- Result
- A typo'd domain is caught before 'confirmed', while a real address still goes through.
Selected code decisions
Cost is screened before the expensive model is ever called
// zero-cost gate — no model call at all
if (PROFANITY.test(lastUserContent)) return warningResponse(priorWarnings);
// cheap Haiku triage before spending Sonnet — skipped once already flagged,
// because the full pass below then runs on Haiku anyway
if (priorWarnings === 0) {
const offTopic = await triageIsOffTopic(client, turns);
if (offTopic === true) return warningResponse(priorWarnings);
}
const heavyModel = priorWarnings >= 1 ? HAIKU : SONNET;A public endpoint has to assume some visitors aren't real recruiters. A regex catches the obvious cases for free; a cheap Haiku call classifies everything else before the expensive model runs, so a stray or hostile message costs a fraction of a cent instead of a full Sonnet call. A repeat offender is cut off entirely, not just slowed down.
The model never gets to say what's true
export function findBookableSlot(config, now, startISO, endISO) {
const matches = generateSlots(config, now).filter(
(s) => s.startISO === new Date(startISO).toISOString(),
);
if (endISO) return matches.find((s) => s.endISO === new Date(endISO).toISOString()) ?? null;
return matches.find((s) => s.durationMin === config.durationsMin[0]) ?? matches[0] ?? null;
}Confirm re-derives every slot from the availability config and rejects anything that isn't an exact start-and-end match, so a slot can't be booked from a stale page, a tampered request, or a model's mistake. The model only ever describes availability in general terms; it never gets to assert that a specific time is free.
Before vs after
The Contact page
Before
Email, LinkedIn, and GitHub links, plus a dashed-border note promising an AI scheduling assistant 'coming in Phase 3.'
After
A working assistant: a recruiter states a rough day or time, sees real slots generated from my actual availability, picks one, and confirms with a full name and a domain-verified email (phone optional), and the interface states exactly what's required before it's required.
Why: The plan called for the scheduler to be a project in its own right, not just a form embedded in a page, so it had to actually ship inside the site it's promoted on, not stay a placeholder note.
This page explains the why. GitHub has the proof of what was built.
View full repository