Skip to content
Naabel.
Live and public

Internship Tracker

Every internship application in one place, from saved posting to answer.

Role
Sole developer across product, frontend, backend, testing and deployment
Type
Personal project
Timeline
Sep 2026 to Oct 2026
Status
Live and public
Desktop browser window showing the Internship Tracker dashboard with stat cards and an applications-over-time chart, beside a phone showing the same dashboard.
Internship Tracker on desktop and mobile.

The problem

I'm applying for internships myself, and the applications multiply fast: postings saved from job boards and career fairs, online assessments with deadlines, interviews over several rounds, and companies that simply never reply. Without one place for all of it, a deadline slips or an application goes quiet and nobody notices.

A spreadsheet holds the rows but not the story. It can't show how an application got to where it is, remind me what's due in my own timezone, or tell me where my applications actually stall: at the assessment, at the interview, or before anyone replies at all.

The goal

One place for every application, from saved posting to answer, that reminds me what's due and shows where applications stall. Other students can sign up too, so each account's data is walled off in every database query, not just hidden in the UI.

Front end
React, TypeScript, Vite, Tailwind CSS, TanStack Query, Zustand
Visuals
Chart.js, dnd-kit
API
Node.js, Express, MongoDB, Mongoose, Zod
Auth & email
JWT, bcrypt, Google OAuth, Resend
Tests & CI
Jest, Vitest, Playwright, axe, GitHub Actions
Hosting
Railway, MongoDB Atlas, S3-compatible storage
Automated tests
1,839
Application stages
8
Server statement coverage
92.8%
Dashboard Lighthouse performance
98

Architecture

Two services on Railway: a React single-page app and an Express API on separate hosts, talking over CORS with a rotating refresh cookie. Routes validate, controllers stay thin, and services hold the logic, each taking the owner's id as its first argument.

  1. React client

    • Table, board, calendar and dashboard
    • TanStack Query cache, optimistic updates
    • Silent refresh on an expired token
    • Light and dark themes
  2. Express API

    • Zod validation at every route
    • Services scoped by user id
    • Rate limits, helmet, CORS allowlist
    • OpenAPI 3 docs at /api/docs
  3. Data, files & jobs

    • MongoDB Atlas through Mongoose
    • S3-compatible file storage
    • Email through Resend, with a retry queue
    • Scheduled digests, nightly purge and demo reset

The flow, and where it fails

One status change end to end. The dashed path is what happens when someone else changed the record first.

Drag a cardor edit a rowapplied at once in the browserAPI checks the ownerand the versionZod at every routeSaved toits historyevery movekeptConflictpromptreload oroverwrite
A stale edit is never written over a newer one. The user sees the conflict and decides.

Six features

Each one shown on the live demo account, whose applications are generated by the seed script. No real person's data.

01

Applications and their history

Eight stages, and every move between them kept.

Each application holds the company, role, location, work mode, stipend, source, priority, tags, deadline, contact and markdown notes. It moves through eight stages: saved, applied, assessment, interview and offer, or rejected, ghosted or withdrawn. Every move is appended to a status history with an optional note, so the detail page shows a real timeline rather than a single “last updated”. Interview rounds and file attachments (PDF, Word or image) live on the same record.

Detail page for a Frontend Engineer Intern application at Razer marked Offer, with notes, an empty attachments drop zone, a details card and a timeline running from 'Added as Saved' through two passed interview rounds to 'Interview to Offer'.
The timeline shows how an application got to where it is. Seeded demo account; the applications are generated, not real.

02

The list

Every filtered view is a link you can share or refresh.

Pagination, sorting and eleven filters run on the server and are mirrored into the URL, so any view survives a refresh. Search covers company, role, tags and notes. Rows can be archived, moved or deleted in bulk, and applications come in by CSV import, with an error reported per row, and go out as CSV or JSON.

Applications table with a search field, Filters, Columns, Export CSV and New application buttons, listing internships at Shopee, AirAsia MOVE, Grab and others with their status, priority, applied date, deadline and tags.
The table, with search, filters, column choice and export. Seeded demo account; the applications are generated, not real.

03

Kanban board

Drag a card to change its stage, by pointer or keyboard.

One column per stage, each with its count. A drop changes the status at once and rolls back if the server refuses. If someone else changed the card first, for example in another tab, the server answers 409 with its current copy, and the board asks whether to reload or overwrite instead of silently replacing either one.

Board with Saved, Applied, Assessment and Interview columns and their counts, each holding cards with company, role, a due or overdue chip, tags and a Move bar.
The board, with overdue deadlines flagged on the cards. Seeded demo account; the applications are generated, not real.

04

Calendar and reminders

Deadlines and interviews in one month, and email before they arrive.

The calendar merges deadlines and interviews into one stream, deadlines in red and interviews in blue. A daily reminder digest and an opt-in weekly summary arrive at a set hour in each user's own timezone, across daylight-saving changes, with a signed one-click unsubscribe.

October 2026 month grid with red deadline chips and blue interview chips for companies such as Shopee, Atlassian and Sea Group, today's date circled, and a 'Pick a day' panel beside it.
Deadlines in red, interviews in blue. Seeded demo account; the applications are generated, not real.

05

Analytics dashboard

Where applications stall, measured against your own pace.

Stat cards compare the last 30 days with the 30 before. Charts show applications over time, the status breakdown, sources, the conversion funnel from applied to offer and the companies applied to most, each with a screen-reader data table behind it. A 12-week activity heatmap measures weekly pace against a goal the user sets.

Four dashboard charts: a status doughnut totalling 64, a bar chart of applications by source led by JobStreet, a conversion funnel from Applied to Offer, and the top companies by applications.
The funnel shows where applications drop off. Seeded demo account; the applications are generated, not real.
Activity card with a 12-week daily heatmap, follow-through figures of 41 added, 53 status updates and 54 active days, and a weekly pace bar chart against a dashed goal line of 5.
Twelve weeks of activity, and weekly pace against the goal.
Phone-width dashboard with stat cards in two columns: total 64, active 25, interviews 8, offers 5, response rate 52.1%, interview rate 16.7%, time to first reply 7.1 days and a weekly goal ring at 2 of 5.
The same dashboard on a phone.

06

Accounts, email and the demo

A real account system, and a demo that needs no sign-up.

Email verification, password reset and email change all use single-use, expiring tokens stored only as hashes. Users can see their signed-in devices and revoke any of them. Closing an account soft-deletes it for 30 days before a nightly job purges it, and everything stored about an account exports as JSON in one click. “Try the demo” signs into a shared sandbox whose password and profile can't be changed, and which is reseeded every night.

Decisions worth defending

The trade-offs behind the design, and why I made each one.

Ownership is enforced in every query

Every service function takes the user's id as its first argument and scopes its query by it. A request can never reach a record without naming its owner.

Why: Hiding another user's data in the UI is not the same as not serving it. A test checks that user A cannot read user B's data through any endpoint.

Refresh tokens rotate in families

A 15-minute access token lives in memory; a 7-day refresh token sits in an httpOnly cookie, is replaced on every use, and is stored only as an HMAC. Each login starts a family, and replaying a used token revokes the whole family.

Why: A stolen refresh token is caught the moment either copy is reused. A 10-second grace window tells two tabs refreshing at once apart from a real replay, so ordinary use never logs anyone out.

Conflicts are shown, not resolved silently

Writes carry the record's version. A stale one gets a 409 with the server's current copy, and the UI offers reload or overwrite.

Why: Last write wins means losing an edit without knowing it. The 409 also says whether it came from a stale read or a true race, which makes both paths testable.

Liveness never fails on a database outage

/health only says the process is up. /ready pings MongoDB and fails while it can't reach it.

Why: A liveness check that failed during a short database blip would make the platform restart a perfectly healthy process.

Email never fails a request

Mail goes through a queue with three attempts, behind one transport interface: SMTP, Resend's HTTP API, or the console when nothing is configured.

Why: Saving an application shouldn't fail because a mail server is slow. The console fallback also means a fresh clone boots and registers with no email setup at all.

Only a commit CI passed can deploy

A GitHub Actions workflow deploys both services by commit SHA once CI is green on main. Railway's own push trigger is switched off.

Why: With the push trigger on, a red commit would ship anyway. Deploying the exact SHA also stops a green run from rebuilding an older commit.

Bundle budgets enforced in CI

Each route has a gzipped size budget, 150 KB for the landing page and 300 KB for the dashboard, and the build fails if Chart.js, dnd-kit or the markdown libraries reach the landing route.

Why: A chunk can be under budget and still drag a heavy library behind it, so the check walks the real import graph rather than guessing from file names.

Time zones go through Intl

Every date boundary, from digest hours to analytics weeks, is computed in the user's own timezone with the browser's and Node's built-in Intl. The previous week is found by stepping back a day and finding that week's start.

Why: Subtracting seven days of milliseconds lands in the wrong week across a daylight-saving change. Intl is the authoritative timezone data, so the rule lives in one place.

Problems I had to solve

Most of them only appeared once the client and the API ran on separate hosts, which is why the end-to-end tests now run that way too.

Logins that dropped users on refresh

Problem
The refresh cookie was SameSite=Strict. With the client and the API on separate hosts, the browser never sent it to the API, so login appeared to work and then bounced the user back to the sign-in page on the next reload.
What I did
Traced it to the difference between an origin and a site: my end-to-end stack differed only by port, which changes the origin but not the site, so no test could see it. The cookie policy now compares the client and API hostnames and relaxes to SameSite=None; Secure when they differ.
Outcome
Sessions survive reloads in production. A new test fails on two cases against the old policy and passes on the new one.

Downloads losing their filenames

Problem
CSV and account exports downloaded under a generic name instead of the dated one the server chose.
What I did
The first end-to-end run against a real cross-origin stack showed why: Content-Disposition isn't a header browsers expose to scripts across origins unless the server lists it. Added it to Access-Control-Expose-Headers.
Outcome
Every deployed build had the bug and local development couldn't show it. Exports now keep their dated names.

A conflict prompt that vanished

Problem
Dragging a card that someone else had changed could make the reload-or-overwrite prompt disappear before it was answered, and “Overwrite” then did nothing. The drag was lost with no error.
What I did
Found the cause: the rollback briefly emptied the board's query, the loading skeleton replaced the board, and the prompt was destroyed with it. Moved the conflict state and the prompt up to the page, which the query can't unmount, and wrote a test that empties the query on purpose while the prompt is open.
Outcome
The prompt and the pending move survive. The test fails on the old code and passes on the new.

Email blocked on Railway

Problem
In production, every SMTP port timed out. Railway blocks outbound SMTP, so no verification or reminder email could leave.
What I did
Added Resend's HTTP API as a third transport behind the existing mail interface, sent over HTTPS with Node's built-in fetch rather than another SDK.
Outcome
Mail reaches inboxes in production. The queue, retries and all six templates stayed unchanged, because only the transport was new.

Lighthouse auditing the wrong page

Problem
A dashboard audit reported a perfect score for a page nobody asked about. Lighthouse's tab had no session, so it had silently measured the login page instead.
What I did
Gave each route its own signed-in browser profile and made the run check the URL Lighthouse actually finished on, not the one requested.
Outcome
The signed-in dashboard scores 98 for performance and 100 for accessibility, best practices and SEO, measured on the page it claims to be.

Weeks that moved across daylight saving

Problem
Week-over-week figures depend on where the user is, and finding last week by subtracting seven days of milliseconds lands in the wrong week across a daylight-saving change.
What I did
Every boundary goes through Intl in the user's timezone, and the previous week is found by stepping back one day and finding the start of that week.
Outcome
Day and week boundaries are tested for two distant timezones, and digests arrive at the same local hour on either side of a clock change.

How I tested it

Three layers, every one of them on every CI run.

The server suite runs against an in-memory MongoDB, so it needs no database running. Coverage is enforced rather than reported: the run fails if services or controllers drop below 70% of statements. Whole-server statement coverage was 92.8% on 29 October 2026.

The end-to-end tests run against a real stack, with a real API, a real database and a real cross-origin boundary, which is where the filename bug surfaced. They cover ten flows, from sign-up and email verification to dragging a card and reloading. Alongside them, axe audits seven screens for zero accessibility violations at any impact level, and a layout audit checks seven routes at 375, 768 and 1440px for sideways scrolling. Retries are set to zero, because a retry hides exactly the intermittency the suite exists to catch.

Test counts from the README, 29 October 2026.
LayerToolsTests
ServerJest, supertest804
ClientVitest, React Testing Library1,003
End to endPlaywright, axe32

1,839 tests in all, and only a commit that passes them is deployed.

What I’d change

  • What I'd do differently: run the end-to-end stack across two separate sites from the first phase. The cookie bug that logged users out only appears when the client and API sit on different sites, and my local stack only differed by port.
  • What I'd add next: a browser extension that saves a posting straight from LinkedIn or a careers page with the company, role and link filled in, and a calendar feed so deadlines and interviews show up in Google or Apple Calendar.
  • What I'd leave alone: no analytics or tracking scripts. Nothing in the app needs them, and leaving them out keeps the privacy page short and true.
  • What surprised me: origin and site are different scopes. A port changes the origin but not the site, and cookies follow the site.

Want the full walkthrough?

The app is live, and the demo account is one click from its landing page. I'm happy to talk through any part of it, including the parts I'd build differently now.