Build and Deploy a React Job Tracker

Build a persistent React job tracker and deploy it to GitHub Pages.

Introduction

30 Second Summary

Job searches quickly spread across emails, calendars, and handwritten notes. A clear view of every opportunity makes it easier to spot the next move.

In this project, you will build a responsive job application tracker using React with TypeScript. You will publish it through GitHub Pages as a public demo that keeps application records after a browser refresh.

What You'll Build

Your finished tracker turns each job application into a clear hiring dashboard that stays useful after refreshes and works from a public link.

By the end of this project, you'll have:

  • A responsive application tracker where each submitted opportunity appears in the table and updates the dashboard totals.
  • A persistent hiring pipeline that keeps records after refreshes and narrows the table by hiring stage.
  • A public portfolio URL where GitHub Actions publishes each update pushed to main.
  • Secret Mission: Document a recruiter-ready QA report covering keyboard navigation, persistence, and stage changes.

Are there any prerequisites?

You should have a Windows PC with Visual Studio Code plus a GitHub account. Step 1 verifies Node.js, npm, and Git before you start building.

Before We Start

You are committing to build a job application tracker for a recruiter or job seeker. The live demo will prove your frontend development skills through a public deployment.

Set Up the React Workspace

Your React app needs a compatible Node.js runtime before it can run. The finished project also needs Git before it can reach GitHub.

This step prepares those tools in Visual Studio Code. You will install the pinned TypeScript and Vite dependencies before launching the starter page.

In this step, get ready to:
  • Verify Node.js, npm, and Git on Windows.
  • Create the project configuration files.
  • Run the starter React app locally.

Why React with Vite?

React gives the tracker typed stateful interactions that strengthen its frontend portfolio signal. Vite turns that code into static assets that fit GitHub Pages.

The project has no server-rendering requirement. A larger server framework would add setup without improving this static application.

Verify the Windows toolchain

Node.js runs the development tools. npm installs the packages listed by the project.

Git tracks the source files before deployment. The version checks confirm that all three tools are available from PowerShell.

  • Press the Windows key to open Windows search.
  • Type Visual Studio Code into the search bar.
  • Press Enter to open Visual Studio Code.
  • Click Terminal in the top menu bar.
  • Click New Terminal to open the integrated terminal.
  • Confirm that the terminal prompt begins with PS.
  • Check the three installed tools by running these commands:
node --version
npm --version
git --version

What do these checks prove?

  • The first command prints the installed Node.js version.
  • The second command confirms that npm is available alongside Node.js.
  • The third command prints the installed Git version.

✔️ I see the target versions

Your Node.js output shows v24.22.0. Your terminal also shows an npm version.

Your Git output confirms the installed Git client. That is the toolchain ready for the project.

ⓧ I see older versions

Older tool versions can cause dependency or build failures. Update the local installations before creating the project files.

  • Download the Windows LTS build from the official Node.js download page.
  • Run the Windows installer for Node.js v24.22.0 LTS.
  • Keep the default Node.js installation options.
  • Download the x64 installer from the official Git for Windows page.
  • Run the installer for Git for Windows 2.56.0(2).
  • Keep the default Git installation options.
  • Close every Visual Studio Code window after both installations finish.
  • Reopen Visual Studio Code through Windows search.
  • Check the updated installations by running these commands:
node --version
npm --version
git --version

What should you see now?

The Node.js line should show v24.22.0. The npm line should show its installed version.

The Git line should confirm that Git is available. The reopened terminal now has access to the updated Windows command paths.

ⓧ A command is missing

A missing command means its development tool is unavailable from the current Windows command path. Install both pinned tools to establish one known starting point.

  • Download the Windows LTS build from the official Node.js download page.
  • Run the Windows installer for Node.js v24.22.0 LTS.
  • Keep the default Node.js installation options.
  • Download the x64 installer from the official Git for Windows page.
  • Run the installer for Git for Windows 2.56.0(2).
  • Keep the default Git installation options.
  • Close every Visual Studio Code window after both installations finish.
  • Reopen Visual Studio Code through Windows search.
  • Check the new installations by running these commands:
node --version
npm --version
git --version

Why reopen Visual Studio Code?

An existing terminal can retain the old Windows command paths. Reopening Visual Studio Code starts a fresh PowerShell session that can find the installed tools.

Still missing a command?

Confirm that every Visual Studio Code window was closed after installation. An older integrated terminal can keep stale command paths.

Copy the visible command output without including personal folder paths. Then ask for help diagnosing the Windows tool path.

Create the project files

The configuration files define the dependency versions and compiler rules. They also connect the browser entry page to the React source code.

  • Click File in the Visual Studio Code menu bar.
  • Click Open Folder.
  • Select Desktop in the folder dialog.
  • Click New Folder.
  • Enter job-application-tracker as the folder name.
  • Select the new job-application-tracker folder.
  • Click Select Folder.
  • Select Yes, I trust the authors if Visual Studio Code asks about workspace trust.

You should see job-application-tracker at the top of the Explorer sidebar. Every project file in this step belongs inside that folder.

  • Click New File in the Explorer sidebar.
  • Enter package.json as the file name.
  • Add the pinned package manifest by pasting this code:
{
  "name": "job-application-tracker",
  "private": true,
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "vite build",
    "preview": "vite preview"
  },
  "dependencies": {
    "react": "19.3.1",
    "react-dom": "19.3.1"
  },
  "devDependencies": {
    "@types/react": "19.3.0",
    "@types/react-dom": "19.3.0",
    "@vitejs/plugin-react": "6.1.2",
    "typescript": "7.0.2",
    "vite": "8.3.4"
  }
}

What does this file control?

  • The scripts connect npm to the Vite development and production commands.
  • The dependencies pin React and React DOM to version 19.3.1.
  • The development dependencies pin TypeScript and Vite. They also pin the React type definitions and Vite React plugin.
  • Press Ctrl+S to save package.json.
  • Confirm that package.json appears in the Explorer sidebar.

Seeing JSON errors?

Check the commas between properties. Confirm that every key uses matching double quotes.

If the errors remain, ask for help checking the package manifest.

  • Click New File in the Explorer sidebar.
  • Enter tsconfig.json as the file name.
  • Add the TypeScript compiler settings by pasting this code:
{
  "compilerOptions": {
    "target": "ES2022",
    "lib": ["ES2022", "DOM", "DOM.Iterable"],
    "module": "ESNext",
    "moduleResolution": "Bundler",
    "jsx": "react-jsx",
    "strict": true,
    "noEmit": true,
    "skipLibCheck": true,
    "types": ["vite/client"]
  },
  "include": ["src"]
}

What do these compiler rules do?

  • Bundler resolution lets Vite resolve project imports.
  • The React JSX setting enables the automatic JSX transform.
  • Strict mode applies stronger TypeScript safety checks.
  • The no-emit setting leaves JavaScript conversion to Vite.
  • Press Ctrl+S to save tsconfig.json.
  • Confirm that tsconfig.json appears directly inside job-application-tracker.

Compiler settings underlined?

Confirm that the file is named tsconfig.json. Check that it sits beside package.json.

For a persistent warning, ask for help checking the TypeScript configuration.

  • Click New File in the Explorer sidebar.
  • Enter vite.config.js as the file name.
  • Configure the React plugin by pasting this code:
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'

export default defineConfig({
  plugins: [react()],
})

What does this configuration do?

The React plugin lets Vite process JSX during development. It also handles the React transformation for production builds.

The GitHub Pages base setting is absent at this stage. Deployment adds that repository-specific path later.

  • Press Ctrl+S to save vite.config.js.
  • Confirm that vite.config.js appears in the Explorer sidebar.

Vite imports underlined?

The packages become available after the installation command later in this substep. Confirm that both import names match the code exactly.

If the warning remains after installation, ask for help checking the Vite configuration.

  • Click New File in the Explorer sidebar.
  • Enter .gitignore as the file name.
  • Add the generated paths that Git should ignore by pasting this code:
node_modules
dist
.vscode

Why ignore these paths?

Dependencies and production output can be regenerated. Editor settings are specific to your local workspace.

Ignoring these paths keeps the future repository focused on source files. Git has not been initialized yet.

  • Press Ctrl+S to save .gitignore.
  • Confirm that .gitignore appears in the Explorer sidebar.
  • Click New File in the Explorer sidebar.
  • Enter index.html as the file name.
  • Add the browser entry page by pasting this code:
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <meta
      name="description"
      content="A React job application tracker with stage filtering and browser persistence."
    />
    <title>Job Application Tracker</title>
  </head>
  <body>
    <div id="root"></div>
    <script type="module" src="/src/main.tsx"></script>
  </body>
</html>

How does the page load React?

The root element provides the mounting point for the interface. The module script loads src/main.tsx as the application entry point.

The viewport metadata helps the page fit smaller screens. The description summarizes the app for browsers and link previews.

  • Press Ctrl+S to save index.html.
  • Confirm that index.html appears in the Explorer sidebar.

HTML structure underlined?

Check that the root identifier uses lowercase letters. Confirm that the module script points to /src/main.tsx.

For another markup error, ask for help checking the browser entry page.

  • Select job-application-tracker in the Explorer sidebar.
  • Click New Folder in the Explorer sidebar.
  • Enter src as the folder name.
  • Select the new src folder.
  • Click New File in the Explorer sidebar.
  • Enter vite-env.d.ts as the file name.
  • Add the Vite client type reference by pasting this code:
/// <reference types="vite/client" />

Why include this reference?

This declaration loads Vite's client-side TypeScript types. It gives the editor access to browser values supplied by Vite.

  • Press Ctrl+S to save src/vite-env.d.ts.
  • Confirm that vite-env.d.ts appears inside the src folder.

The package manifest now gives npm the exact dependency list. Installing it creates the local dependency folder and lockfile.

  • Click Terminal in the top menu bar.
  • Click New Terminal to open a PowerShell terminal for the project folder.
  • Confirm that the prompt ends with job-application-tracker.
  • Install the pinned project dependencies by running this command:
npm install

What does npm create?

npm reads package.json before downloading the pinned packages into node_modules.

The command also generates package-lock.json with the resolved dependency tree. The deployment workflow later uses that lockfile for repeatable installation.

  • Confirm that the terminal returns to a PowerShell prompt without an installation error.
  • Confirm that node_modules appears in the Explorer sidebar.
  • Confirm that package-lock.json appears in the Explorer sidebar.

That installation is complete. React 19.3.1 and React DOM 19.3.1 are now available inside the project.

TypeScript 7.0.2 and Vite 8.3.4 are ready for the starter app.

Installation failed?

Confirm that package.json is saved. Check that the PowerShell prompt points to job-application-tracker.

If npm reports a dependency or network problem, ask for help interpreting the installation output.

Run the starter React app

The browser entry page now needs a React entry file and a component. This first component stays small so the complete toolchain can prove itself before the dashboard grows.

  • Select the src folder in the Explorer sidebar.
  • Click New File in the Explorer sidebar.
  • Enter main.tsx as the file name.
  • Add the React browser entry point by pasting this code:
import { createRoot } from 'react-dom/client'
import App from './App'
import './styles.css'

const rootElement = document.getElementById('root')

if (!rootElement) {
  throw new Error('Root element not found')
}

createRoot(rootElement).render(<App />)

What does this entry point do?

  • The file imports the React DOM client renderer.
  • The root lookup connects React to the matching element in index.html.
  • The error stops rendering when that browser element is missing.
  • The final line renders the App component.
  • Press Ctrl+S to save src/main.tsx.
  • Confirm that main.tsx appears inside the src folder.

React DOM import underlined?

Confirm that the dependency installation completed successfully. Check that the import path is react-dom/client.

If the import remains unresolved, ask for help checking the entry file.

  • Select the src folder in the Explorer sidebar.
  • Click New File in the Explorer sidebar.
  • Enter App.tsx as the file name.
  • Add the temporary starter component by pasting this code:
import { useState } from 'react'

export default function App() {
  return <h1>Job Application Tracker</h1>
}

What does this component render?

The default export gives main.tsx the component it imports. Its JSX returns the heading that proves React reached the browser.

The useState import prepares the component for the typed application state added in the next step.

  • Press Ctrl+S to save src/App.tsx.
  • Confirm that App.tsx appears inside the src folder.

App component underlined?

Confirm that the filename starts with a capital A.

For another component error, ask for help checking the starter component.

  • Select the src folder in the Explorer sidebar.
  • Click New File in the Explorer sidebar.
  • Enter styles.css as the file name.
  • Press Ctrl+S to save the empty src/styles.css file.
  • Confirm that styles.css appears inside the src folder.

The stylesheet stays empty during this step. The dashboard styles arrive with the visible interface in the next step.

✔️ Awesome, I've got everything!

Save every open file. Your Explorer should show the configuration files and the three source files.

You should also see the generated package-lock.json and node_modules items.

ⓧ I'd like to double check the full code

Compare each file below with the matching file inside job-application-tracker.

{
  "name": "job-application-tracker",
  "private": true,
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "vite build",
    "preview": "vite preview"
  },
  "dependencies": {
    "react": "19.3.1",
    "react-dom": "19.3.1"
  },
  "devDependencies": {
    "@types/react": "19.3.0",
    "@types/react-dom": "19.3.0",
    "@vitejs/plugin-react": "6.1.2",
    "typescript": "7.0.2",
    "vite": "8.3.4"
  }
}

Package File Check

Confirm that every package version matches. The scripts should contain the development and production commands.

{
  "compilerOptions": {
    "target": "ES2022",
    "lib": ["ES2022", "DOM", "DOM.Iterable"],
    "module": "ESNext",
    "moduleResolution": "Bundler",
    "jsx": "react-jsx",
    "strict": true,
    "noEmit": true,
    "skipLibCheck": true,
    "types": ["vite/client"]
  },
  "include": ["src"]
}

TypeScript File Check

Confirm that the compiler uses Bundler resolution. The included source folder should be src.

import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'

export default defineConfig({
  plugins: [react()],
})

Vite File Check

Confirm that the React plugin is present. This version must not contain a GitHub Pages base setting.

node_modules
dist
.vscode

Ignore File Check

Confirm that all three generated or editor-specific paths appear on separate lines.

<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <meta
      name="description"
      content="A React job application tracker with stage filtering and browser persistence."
    />
    <title>Job Application Tracker</title>
  </head>
  <body>
    <div id="root"></div>
    <script type="module" src="/src/main.tsx"></script>
  </body>
</html>

Browser Entry Check

Confirm that the document contains the responsive viewport metadata. The body should contain the root element and module script.

/// <reference types="vite/client" />

Vite Types Check

Confirm that this declaration file contains the single Vite client type reference.

import { createRoot } from 'react-dom/client'
import App from './App'
import './styles.css'

const rootElement = document.getElementById('root')

if (!rootElement) {
  throw new Error('Root element not found')
}

createRoot(rootElement).render(<App />)

React Entry Check

Confirm that the entry file imports the app and stylesheet. The root lookup should match the identifier in index.html.

import { useState } from 'react'

export default function App() {
  return <h1>Job Application Tracker</h1>
}

Starter Component Check

Confirm that the component returns only the Job Application Tracker heading. The state import remains ready for the next step.

Confirm that src/styles.css exists as an empty file.

Leave the generated package-lock.json unchanged. npm created it from the pinned package manifest.

Vite now has everything required to serve the project. Its development server stays active while you view the app in your browser.

Before you run the app, what heading do you expect React to place on the page?

  • Return to the integrated PowerShell terminal.
  • Start the Vite development server by running this command:
npm run dev

What does this command do?

npm runs the development script from package.json. Vite starts a local server at http://localhost:5173.

The server keeps control of this terminal while it runs. Leave the process active for the next step.

  • Open http://localhost:5173 from the local address shown in the terminal.

You should see the Job Application Tracker heading in the browser. This proves that Node.js and npm are working with TypeScript and Vite.

You also proved that React and React DOM can render inside the page. That is the first working slice of your tracker.

Starter page not loading?

Confirm that the Vite process is still running. Check that the browser address is exactly http://localhost:5173.

If the terminal shows a compile error, compare its named file with the full-code tab. You can also ask for help with the Vite startup error.

Your React workspace is running from the pinned toolchain. Next, you will turn the single heading into a typed dashboard with starter application data.

Build the First Dashboard

Your React workspace now runs in the browser. The starter heading proves the toolchain works.

A useful tracker needs to show the current hiring pipeline before forms or persistence add complexity. In this step, you will model three applications with TypeScript and turn them into a responsive dashboard.

In this step, get ready to:
  • Model hiring stages and application records with explicit types.
  • Derive dashboard totals from React state.
  • Render a responsive application table with stage badges.
Define the application data model

A typed data model gives every application the same shape. It also limits hiring stages to the values the dashboard understands.

  • In the VS Code Explorer sidebar, select src/App.tsx.
  • Add these type definitions and stage options below the existing import:
type Stage = 'Applied' | 'Interview' | 'Offer' | 'Rejected'
type StageFilter = 'All' | Stage

type Application = {
  id: string
  company: string
  role: string
  date: string
  stage: Stage
}

const stageOptions: Stage[] = ['Applied', 'Interview', 'Offer', 'Rejected']

What does this model do?

  • The Stage union restricts each record to four supported hiring stages.
  • The StageFilter union includes every stage plus the future All filter.
  • The Application type requires an ID plus the company details shown in the table.
  • The stageOptions array keeps the supported stage labels in one place.
  • Save src/App.tsx.
  • Check the running Vite page for the existing Job Application Tracker heading.

You should still see the starter heading without a compilation error. That confirms the new types are valid.

Seeing a TypeScript error?

Check each union value for matching quotation marks. Confirm that every property in Application ends with its expected type.

Compare the position of the new code with the import at the top of src/App.tsx. The type definitions belong outside the App function.

Help me fix my application types

The dashboard needs realistic records before it can calculate totals. Three starter applications provide one applied role plus one interview plus one offer.

  • Add the starter records below stageOptions in src/App.tsx by pasting this code:
const starterApplications: Application[] = [
  {
    id: 'starter-1',
    company: 'Northstar Labs',
    role: 'Frontend Developer',
    date: '2026-10-08',
    stage: 'Applied',
  },
  {
    id: 'starter-2',
    company: 'BrightPath Studio',
    role: 'Junior Software Engineer',
    date: '2026-10-11',
    stage: 'Interview',
  },
  {
    id: 'starter-3',
    company: 'Harbor Systems',
    role: 'UI Engineer',
    date: '2026-10-14',
    stage: 'Offer',
  },
]

Why type the starter records?

The Application[] annotation checks every record against the data model. A missing company or unsupported stage now becomes a TypeScript error before it reaches the table.

  • Save src/App.tsx.
  • Check the running page for the existing heading without a Vite error overlay.

You should see the starter heading remain visible. The three records now compile as valid application data.

Does Vite show an error overlay?

Check that every record has a closing brace. Confirm that commas separate all three records.

Make sure each stage matches one of the four values in Stage exactly.

Help me debug the starter records

Render the dashboard from state

React state makes the records available to the interface. The dashboard totals are derived from that state so they always describe the same application list.

  • Replace the current App function with this stateful hero and count logic:
export default function App() {
  const [applications, setApplications] = useState<Application[]>(starterApplications)

  const activeCount = applications.filter(
    (application) =>
      application.stage === 'Applied' || application.stage === 'Interview',
  ).length
  const interviewCount = applications.filter(
    (application) => application.stage === 'Interview',
  ).length
  const offerCount = applications.filter(
    (application) => application.stage === 'Offer',
  ).length

  return (
    <main className="app-shell">
      <header className="hero">
        <p className="eyebrow">Career dashboard</p>
        <h1>Job Application Tracker</h1>
        <p>Track every opportunity from first application to final decision.</p>
      </header>
    </main>
  )
}

How are the totals derived?

  • The applications state starts with the three typed records.
  • The activeCount calculation includes applications at the Applied or Interview stage.
  • The interview and offer calculations filter the same state for their matching stages.
  • The hero introduces the tracker before the dashboard details.
  • Save src/App.tsx.
  • Return to the browser tab running at http://localhost:5173.

You should see the Career dashboard label plus the tracker heading plus its description. The page is still unstyled.

Still seeing only the old heading?

Confirm that you replaced the complete previous App function. Check that the Vite development server still runs in the PowerShell terminal.

Help me refresh the React hero

  • Insert this summary section immediately after the closing header tag:
      <section className="summary-grid" aria-label="Application summary">
        <article className="summary-card">
          <span>Total</span>
          <strong>{applications.length}</strong>
        </article>
        <article className="summary-card">
          <span>Active</span>
          <strong>{activeCount}</strong>
        </article>
        <article className="summary-card">
          <span>Interviews</span>
          <strong>{interviewCount}</strong>
        </article>
        <article className="summary-card">
          <span>Offers</span>
          <strong>{offerCount}</strong>
        </article>
      </section>

What do the cards show?

Each card reads a value from the current applications state or one of its derived counts. This keeps the dashboard values connected to the records they summarize.

  • Save src/App.tsx.
  • Return to the running page to inspect the four totals.

You should see Total as 3 plus Active as 2 plus Interviews as 1 plus Offers as 1.

Are the card values missing?

Check that the summary section sits inside the main element. Confirm that each JavaScript value uses braces inside JSX.

Help me fix the summary cards

The cards reveal the pipeline at a glance. A table now gives each total a visible source record.

  • Insert this empty table panel after the summary section:
      <section className="panel">
        <div className="section-heading list-heading">
          <div>
            <p className="eyebrow">Pipeline</p>
            <h2>Your applications</h2>
          </div>
        </div>

        <div className="table-wrap">
          <table>
            <thead>
              <tr>
                <th>Company</th>
                <th>Role</th>
                <th>Applied</th>
                <th>Stage</th>
                <th>Action</th>
              </tr>
            </thead>
            <tbody>
            </tbody>
          </table>
        </div>
      </section>

Why wrap the table?

The panel separates the application list from the summary cards. The table-wrap container provides a safe horizontal scroll area when the table becomes wider than its screen.

  • Save src/App.tsx.
  • Return to the running page to inspect the table headings.

You should see the Pipeline heading plus five table columns. The body is empty until the next edit maps the application records.

Do the table headings fail to render?

Check that the panel appears after the summary section. Confirm that every opening table tag has a matching closing tag.

Help me repair the table structure

  • Add this record mapping between the opening and closing tbody tags:
              {applications.map((application) => (
                <tr key={application.id}>
                  <td data-label="Company">{application.company}</td>
                  <td data-label="Role">{application.role}</td>
                  <td data-label="Applied">{application.date}</td>
                  <td data-label="Stage">
                    <span
                      className={`stage-select stage-${application.stage.toLowerCase()}`}
                    >
                      {application.stage}
                    </span>
                  </td>
                  <td data-label="Action">
                    <button className="text-button" type="button">
                      Remove
                    </button>
                  </td>
                </tr>
              ))}

How does the table stay connected to state?

  • The map() call creates one row for each object in applications.
  • The record ID gives React a stable key for each row.
  • The stage class converts each stage to lowercase so the matching color rule can style it.
  • The Remove button is visible for the dashboard layout. Its behavior arrives with the interactive controls in the next step.
  • Save src/App.tsx.
  • Return to the running page to inspect the application rows.

You should see Northstar Labs plus BrightPath Studio plus Harbor Systems. Each row includes its role plus date plus stage plus a Remove button.

Are the application rows missing?

Confirm that the mapping code sits inside tbody. Check that the callback uses the singular name application consistently.

Help me render the application rows

✔️ Awesome, I've got everything!

Your dashboard structure is complete. Save src/App.tsx before styling it.

ⓧ I'd like to double check the full code

Compare your complete src/App.tsx file with this version:

import { useState } from 'react'

type Stage = 'Applied' | 'Interview' | 'Offer' | 'Rejected'
type StageFilter = 'All' | Stage

type Application = {
  id: string
  company: string
  role: string
  date: string
  stage: Stage
}

const stageOptions: Stage[] = ['Applied', 'Interview', 'Offer', 'Rejected']

const starterApplications: Application[] = [
  {
    id: 'starter-1',
    company: 'Northstar Labs',
    role: 'Frontend Developer',
    date: '2026-10-08',
    stage: 'Applied',
  },
  {
    id: 'starter-2',
    company: 'BrightPath Studio',
    role: 'Junior Software Engineer',
    date: '2026-10-11',
    stage: 'Interview',
  },
  {
    id: 'starter-3',
    company: 'Harbor Systems',
    role: 'UI Engineer',
    date: '2026-10-14',
    stage: 'Offer',
  },
]

export default function App() {
  const [applications, setApplications] = useState<Application[]>(starterApplications)

  const activeCount = applications.filter(
    (application) =>
      application.stage === 'Applied' || application.stage === 'Interview',
  ).length
  const interviewCount = applications.filter(
    (application) => application.stage === 'Interview',
  ).length
  const offerCount = applications.filter(
    (application) => application.stage === 'Offer',
  ).length

  return (
    <main className="app-shell">
      <header className="hero">
        <p className="eyebrow">Career dashboard</p>
        <h1>Job Application Tracker</h1>
        <p>Track every opportunity from first application to final decision.</p>
      </header>

      <section className="summary-grid" aria-label="Application summary">
        <article className="summary-card">
          <span>Total</span>
          <strong>{applications.length}</strong>
        </article>
        <article className="summary-card">
          <span>Active</span>
          <strong>{activeCount}</strong>
        </article>
        <article className="summary-card">
          <span>Interviews</span>
          <strong>{interviewCount}</strong>
        </article>
        <article className="summary-card">
          <span>Offers</span>
          <strong>{offerCount}</strong>
        </article>
      </section>

      <section className="panel">
        <div className="section-heading list-heading">
          <div>
            <p className="eyebrow">Pipeline</p>
            <h2>Your applications</h2>
          </div>
        </div>

        <div className="table-wrap">
          <table>
            <thead>
              <tr>
                <th>Company</th>
                <th>Role</th>
                <th>Applied</th>
                <th>Stage</th>
                <th>Action</th>
              </tr>
            </thead>
            <tbody>
              {applications.map((application) => (
                <tr key={application.id}>
                  <td data-label="Company">{application.company}</td>
                  <td data-label="Role">{application.role}</td>
                  <td data-label="Applied">{application.date}</td>
                  <td data-label="Stage">
                    <span
                      className={`stage-select stage-${application.stage.toLowerCase()}`}
                    >
                      {application.stage}
                    </span>
                  </td>
                  <td data-label="Action">
                    <button className="text-button" type="button">
                      Remove
                    </button>
                  </td>
                </tr>
              ))}
            </tbody>
          </table>
        </div>
      </section>
    </main>
  )
}
Style the responsive dashboard

The structure now contains every visible dashboard element. CSS turns that structure into a readable desktop layout plus a stacked mobile view.

  • In the VS Code Explorer sidebar, select src/styles.css.
  • Add the global page foundation by pasting this code at the top of the empty file:
:root {
  font-family:
    Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
    sans-serif;
  color: #172033;
  background: #eef2ff;
  font-synthesis: none;
  text-rendering: optimizeLegibility;
}

* {
  box-sizing: border-box;
}

body {
  margin: 0;
  min-width: 320px;
  min-height: 100vh;
  background:
    radial-gradient(circle at top left, #dbeafe 0, transparent 34%),
    #f8fafc;
}

button,
input,
select {
  font: inherit;
}

What does the foundation control?

The root rules establish the page typography plus foreground color. The body rules remove the browser margin and add the dashboard background.

The universal box sizing rule keeps element widths predictable when padding is added.

  • Save src/styles.css.
  • Return to the running page to inspect the background plus typography.

You should see a pale background with a blue glow in the top-left corner. The text now uses the system font stack.

Does the page still use plain browser styling?

Confirm that src/main.tsx still imports ./styles.css. Check that you saved the CSS file inside the src folder.

Help me connect the stylesheet

  • Add the page shell plus hero styling below the existing CSS:
button,
select {
  cursor: pointer;
}

.app-shell {
  width: min(1120px, calc(100% - 32px));
  margin: 0 auto;
  padding: 48px 0 64px;
}

.hero {
  padding: 36px;
  color: #ffffff;
  background: linear-gradient(135deg, #312e81, #2563eb);
  border-radius: 24px;
  box-shadow: 0 24px 55px rgba(49, 46, 129, 0.22);
}

.hero h1,
.section-heading h2 {
  margin: 0;
}

How does the hero take shape?

The shell limits the content width while keeping it centered. The hero uses padding plus a blue gradient plus rounded corners to separate the product introduction from the page background.

  • Save src/styles.css.
  • Return to the running page to inspect the hero.

You should see the heading inside a wide blue panel with rounded corners. The dashboard now has a clear visual starting point.

Is the hero still plain text?

Check that the JSX uses className="hero". Confirm that the CSS selector starts with a full stop.

Help me style the dashboard hero

  • Add the hero text plus summary grid rules below the existing CSS:
.hero h1 {
  max-width: 700px;
  font-size: clamp(2rem, 5vw, 3.75rem);
  line-height: 1.05;
}

.hero > p:last-child {
  max-width: 620px;
  margin: 16px 0 0;
  color: #dbeafe;
  font-size: 1.05rem;
}

.eyebrow {
  margin: 0 0 8px;
  color: #93c5fd;
  font-size: 0.78rem;
  font-weight: 800;
  letter-spacing: 0.14em;
  text-transform: uppercase;
}

.panel .eyebrow {
  color: #4f46e5;
}

.summary-grid {
  display: grid;
  grid-template-columns: repeat(4, 1fr);
  gap: 16px;
  margin: 20px 0;
}

Why use a grid for the totals?

The four equal grid columns give every summary value the same visual weight. The heading uses a flexible size so it scales with the available screen width.

  • Save src/styles.css.
  • Return to the running page to inspect the summary layout.

You should see the four totals arranged in a single row on a wide browser window. The hero heading should also appear larger.

Do the totals remain stacked?

Check that the summary section uses className="summary-grid". Confirm that the CSS declaration uses display: grid.

Help me fix the summary grid

  • Add the card plus panel styling below the existing CSS:
.summary-card,
.panel {
  background: #ffffff;
  border: 1px solid #e2e8f0;
  box-shadow: 0 14px 35px rgba(15, 23, 42, 0.07);
}

.summary-card {
  display: flex;
  align-items: center;
  justify-content: space-between;
  min-height: 104px;
  padding: 22px;
  border-radius: 18px;
}

.summary-card span {
  color: #64748b;
  font-weight: 700;
}

.summary-card strong {
  color: #312e81;
  font-size: 2rem;
}

.panel {
  margin-top: 20px;
  padding: 28px;
  border-radius: 20px;
}

What separates the dashboard sections?

The cards plus table panel share a white surface plus a soft border plus a shadow. Larger number styling makes each count the focus of its card.

  • Save src/styles.css.
  • Return to the running page to inspect the cards plus table panel.

You should see four white summary cards plus a separate white application panel. Each total should stand out in larger purple text.

Are the cards missing their white surfaces?

Check that every summary item uses className="summary-card". Confirm that the shared selector includes both .summary-card and .panel.

Help me style the summary cards

  • Add the table layout rules below the existing CSS:
.section-heading,
.list-heading {
  display: flex;
  align-items: end;
  justify-content: space-between;
  gap: 20px;
  margin-bottom: 22px;
}

.table-wrap {
  overflow-x: auto;
}

table {
  width: 100%;
  border-collapse: collapse;
}

th,
td {
  padding: 15px 12px;
  text-align: left;
  border-bottom: 1px solid #e2e8f0;
}

th {
  color: #64748b;
  font-size: 0.78rem;
  letter-spacing: 0.06em;
  text-transform: uppercase;
}

How does the table stay readable?

The table fills its panel and collapses adjacent borders into clean row dividers. Uppercase headers create a visual label row without competing with the application data.

  • Save src/styles.css.
  • Return to the running page to inspect the table spacing.

You should see aligned columns with padded cells plus subtle dividers. The column headings should appear as small uppercase labels.

Does the table look compressed?

Check that the th plus td selector contains the padding rule. Confirm that the table uses width: 100%.

Help me fix the table spacing

  • Add the stage badge plus action styling below the existing CSS:
.stage-select {
  min-width: 118px;
  min-height: 38px;
  padding: 7px 10px;
  font-weight: 800;
  border: 0;
}

.stage-applied {
  color: #1d4ed8;
  background: #dbeafe;
}

.stage-interview {
  color: #7c3aed;
  background: #ede9fe;
}

.stage-offer {
  color: #047857;
  background: #d1fae5;
}

.stage-rejected {
  color: #b91c1c;
  background: #fee2e2;
}

.text-button {
  padding: 7px 0;
  color: #b91c1c;
  font-weight: 800;
  background: transparent;
  border: 0;
}

Why give each stage a color?

The stage class pairs each hiring state with a distinct foreground plus background color. This makes the pipeline easier to scan while preserving the written stage label.

The action button uses a transparent background so it reads as a table action instead of the page's primary action.

  • Save src/styles.css.
  • Return to the running page to compare the three stage badges.

You should see a blue Applied badge plus a purple Interview badge plus a green Offer badge. Each row should also show a red Remove action.

Do the stages have no color?

Check that the stage text contains one of the supported values with its original capitalization. Confirm that the JSX converts the value to lowercase when it builds the class name.

Help me match the stage classes

  • Add the desktop-to-tablet layout rule below the existing CSS:
@media (max-width: 900px) {
  .summary-grid {
    grid-template-columns: repeat(2, 1fr);
  }
}

What changes on a tablet-sized screen?

The summary grid changes from four columns to two when the viewport reaches 900px or less. This keeps each card wide enough for its label plus value.

  • Save src/styles.css.
  • Resize the browser below 900px wide.

You should see the summary cards rearrange into two columns. The table should remain inside its white panel.

Do the cards stay in four columns?

Confirm that the media query closes with two braces. Make sure the browser viewport is narrower than 900px.

Help me test the tablet breakpoint

  • Add the mobile shell plus stacked layout rules below the existing CSS:
@media (max-width: 640px) {
  .app-shell {
    width: min(100% - 20px, 1120px);
    padding-top: 18px;
  }

  .hero,
  .panel {
    padding: 22px;
    border-radius: 16px;
  }

  .summary-grid {
    grid-template-columns: 1fr;
  }

  .list-heading {
    align-items: stretch;
    flex-direction: column;
  }

  thead {
    position: absolute;
    width: 1px;
    height: 1px;
    padding: 0;
    margin: -1px;
    overflow: hidden;
    clip: rect(0, 0, 0, 0);

How does the mobile layout begin?

The shell uses smaller spacing below 640px. The summary cards become one column while the table header moves off-screen so each row can use its own labels.

  • Add the remaining mobile table rules directly below that code:
    white-space: nowrap;
    border: 0;
  }

  tr {
    display: grid;
    gap: 10px;
    padding: 16px 0;
    border-bottom: 1px solid #e2e8f0;
  }

  td {
    display: grid;
    grid-template-columns: 105px 1fr;
    align-items: center;
    padding: 0;
    border: 0;
  }

  td::before {
    content: attr(data-label);
    color: #64748b;
    font-size: 0.78rem;
    font-weight: 800;
    text-transform: uppercase;
  }

  .empty-state {
    display: block;
  }

  .empty-state::before {
    content: none;
  }
}

How does the table become a mobile list?

Each table row becomes a grid of labeled values. The data-label attributes provide the Company plus Role plus Applied plus Stage plus Action labels on narrow screens.

  • Save src/styles.css.

Before you resize the browser, what layout do you expect for the four summary cards plus the five table columns?

  • Resize the browser below 640px wide.

You should see the four summary cards in one column. Each application should become a stacked group with Company plus Role plus Applied plus Stage plus Action labels.

Does the mobile table stay wide?

Check that every table cell in src/App.tsx has the expected data-label value. Confirm that the final mobile media query has its closing brace.

Help me fix the mobile table

✔️ Awesome, I've got everything!

Your responsive dashboard styling is complete. Save src/styles.css before capturing the result.

ⓧ I'd like to double check the full code

Compare your complete src/styles.css file with this version:

:root {
  font-family:
    Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
    sans-serif;
  color: #172033;
  background: #eef2ff;
  font-synthesis: none;
  text-rendering: optimizeLegibility;
}

* {
  box-sizing: border-box;
}

body {
  margin: 0;
  min-width: 320px;
  min-height: 100vh;
  background:
    radial-gradient(circle at top left, #dbeafe 0, transparent 34%),
    #f8fafc;
}

button,
input,
select {
  font: inherit;
}

button,
select {
  cursor: pointer;
}

.app-shell {
  width: min(1120px, calc(100% - 32px));
  margin: 0 auto;
  padding: 48px 0 64px;
}

.hero {
  padding: 36px;
  color: #ffffff;
  background: linear-gradient(135deg, #312e81, #2563eb);
  border-radius: 24px;
  box-shadow: 0 24px 55px rgba(49, 46, 129, 0.22);
}

.hero h1,
.section-heading h2 {
  margin: 0;
}

.hero h1 {
  max-width: 700px;
  font-size: clamp(2rem, 5vw, 3.75rem);
  line-height: 1.05;
}

.hero > p:last-child {
  max-width: 620px;
  margin: 16px 0 0;
  color: #dbeafe;
  font-size: 1.05rem;
}

.eyebrow {
  margin: 0 0 8px;
  color: #93c5fd;
  font-size: 0.78rem;
  font-weight: 800;
  letter-spacing: 0.14em;
  text-transform: uppercase;
}

.panel .eyebrow {
  color: #4f46e5;
}

.summary-grid {
  display: grid;
  grid-template-columns: repeat(4, 1fr);
  gap: 16px;
  margin: 20px 0;
}

.summary-card,
.panel {
  background: #ffffff;
  border: 1px solid #e2e8f0;
  box-shadow: 0 14px 35px rgba(15, 23, 42, 0.07);
}

.summary-card {
  display: flex;
  align-items: center;
  justify-content: space-between;
  min-height: 104px;
  padding: 22px;
  border-radius: 18px;
}

.summary-card span {
  color: #64748b;
  font-weight: 700;
}

.summary-card strong {
  color: #312e81;
  font-size: 2rem;
}

.panel {
  margin-top: 20px;
  padding: 28px;
  border-radius: 20px;
}

.section-heading,
.list-heading {
  display: flex;
  align-items: end;
  justify-content: space-between;
  gap: 20px;
  margin-bottom: 22px;
}

.table-wrap {
  overflow-x: auto;
}

table {
  width: 100%;
  border-collapse: collapse;
}

th,
td {
  padding: 15px 12px;
  text-align: left;
  border-bottom: 1px solid #e2e8f0;
}

th {
  color: #64748b;
  font-size: 0.78rem;
  letter-spacing: 0.06em;
  text-transform: uppercase;
}

.stage-select {
  min-width: 118px;
  min-height: 38px;
  padding: 7px 10px;
  font-weight: 800;
  border: 0;
}

.stage-applied {
  color: #1d4ed8;
  background: #dbeafe;
}

.stage-interview {
  color: #7c3aed;
  background: #ede9fe;
}

.stage-offer {
  color: #047857;
  background: #d1fae5;
}

.stage-rejected {
  color: #b91c1c;
  background: #fee2e2;
}

.text-button {
  padding: 7px 0;
  color: #b91c1c;
  font-weight: 800;
  background: transparent;
  border: 0;
}

@media (max-width: 900px) {
  .summary-grid {
    grid-template-columns: repeat(2, 1fr);
  }
}

@media (max-width: 640px) {
  .app-shell {
    width: min(100% - 20px, 1120px);
    padding-top: 18px;
  }

  .hero,
  .panel {
    padding: 22px;
    border-radius: 16px;
  }

  .summary-grid {
    grid-template-columns: 1fr;
  }

  .list-heading {
    align-items: stretch;
    flex-direction: column;
  }

  thead {
    position: absolute;
    width: 1px;
    height: 1px;
    padding: 0;
    margin: -1px;
    overflow: hidden;
    clip: rect(0, 0, 0, 0);
    white-space: nowrap;
    border: 0;
  }

  tr {
    display: grid;
    gap: 10px;
    padding: 16px 0;
    border-bottom: 1px solid #e2e8f0;
  }

  td {
    display: grid;
    grid-template-columns: 105px 1fr;
    align-items: center;
    padding: 0;
    border: 0;
  }

  td::before {
    content: attr(data-label);
    color: #64748b;
    font-size: 0.78rem;
    font-weight: 800;
    text-transform: uppercase;
  }

  .empty-state {
    display: block;
  }

  .empty-state::before {
    content: none;
  }
}

That is the first useful version complete. Your typed records now drive a responsive dashboard that explains the hiring pipeline at a glance.

Next up, you will add the form plus row controls that turn this dashboard into an interactive tracker.

Add Applications and Expose the Refresh Problem

Your dashboard already turns three typed records into summary cards and a responsive table. Now it needs controls that let you manage the pipeline from the page.

This step adds an in-memory controlled form plus editable table rows. You will then refresh the browser to test how long those changes remain in React state.

In this step, get ready to:
  • Build a controlled form that adds typed application records.
  • Update stages and remove records directly from the table.
  • Refresh the tracker to expose the limits of in-memory state.
Build the controlled application form

A controlled form keeps each field value in React state. Each input shows its matching state value and updates that value whenever you type.

  • Switch back to src/App.tsx in Visual Studio Code.
  • Find the existing applications state inside App().
  • Add the four form state values directly below it using this code:
  const [company, setCompany] = useState('')
  const [role, setRole] = useState('')
  const [date, setDate] = useState('')
  const [stage, setStage] = useState<Stage>('Applied')

What does this state hold?

  • The company and role values hold the two text inputs.
  • The date value holds the selected application date.
  • The stage value starts every new record at Applied.
  • Each setter gives its matching form control a single source of truth.
  • Find the closing </section> for the summary-grid section.
  • Insert the first version of the application form directly below that section using this code:
      <section className="panel">
        <div className="section-heading">
          <div>
            <p className="eyebrow">New opportunity</p>
            <h2>Add an application</h2>
          </div>
        </div>

        <form
          className="application-form"
          onSubmit={(event) => {
            event.preventDefault()
          }}
        >
          <label>
            Company
            <input
              value={company}
              onChange={(event) => setCompany(event.target.value)}
              placeholder="Example Company"
              required
            />
          </label>
          <button className="primary-button" type="submit">
            Add application
          </button>
        </form>
      </section>

How is the company field controlled?

  • The value property displays the current company state.
  • The onChange handler stores each edit with setCompany.
  • The submit handler currently prevents the browser from reloading the page.
  • Save src/App.tsx.
  • Return to the tracker in your browser.

You should see an Add an application panel with a company field and submit button. The dashboard remains visible above it.

  • Return to the form in src/App.tsx.
  • Insert the role and date fields directly after the company label using this code:
          <label>
            Role
            <input
              value={role}
              onChange={(event) => setRole(event.target.value)}
              placeholder="Frontend Developer"
              required
            />
          </label>
          <label>
            Application date
            <input
              type="date"
              value={date}
              onChange={(event) => setDate(event.target.value)}
              required
            />
          </label>

What do these fields add?

  • The role input follows the same controlled pattern as the company input.
  • The date input uses the browser's date control while storing the chosen value in date.
  • The required attributes stop incomplete records from reaching the submit handler.
  • Insert the stage field directly after the application date label using this code:
          <label>
            Stage
            <select
              value={stage}
              onChange={(event) => setStage(event.target.value as Stage)}
            >
              {stageOptions.map((option) => (
                <option key={option} value={option}>
                  {option}
                </option>
              ))}
            </select>
          </label>

How does the stage list stay typed?

The select builds its options from stageOptions. The as Stage assertion tells TypeScript that the selected value matches the existing stage union.

  • Save src/App.tsx.
  • Return to the browser to inspect the expanded form.

You should see labeled controls for company, role, application date, and stage. The stage menu should list all four values from stageOptions.

The form now has the right fields, but its layout needs to join the dashboard's responsive design. The next CSS additions turn those controls into a usable grid.

  • Switch back to src/styles.css.
  • Find the closing brace for the existing body rule.
  • Add the shared control rules directly below it using this code:
button,
input,
select {
  font: inherit;
}

button,
select {
  cursor: pointer;
}

What do these control rules do?

The controls inherit the page font for a consistent interface. Buttons and select menus also show a pointer cursor when they are interactive.

  • Find the existing .section-heading rule.
  • Add the responsive form grid directly below that rule using this code:
.application-form {
  display: grid;
  grid-template-columns: 1.2fr 1.4fr 1fr 1fr auto;
  align-items: end;
  gap: 14px;
}

How does the form grid work?

The grid gives extra width to company and role while keeping the date and stage compact. The final column fits the submit button.

  • Save src/styles.css.
  • Return to the browser to check the form layout.

On a wide browser window, you should see the four fields and button arranged in one row. The existing mobile rule stacks the form into one column on a narrow screen.

  • Return to src/styles.css.
  • Add the field and focus styles directly below .application-form using this code:
.application-form label,
.filter-control {
  display: grid;
  gap: 7px;
  color: #475569;
  font-size: 0.86rem;
  font-weight: 750;
}

input,
select {
  width: 100%;
  min-height: 44px;
  padding: 10px 12px;
  color: #172033;
  background: #ffffff;
  border: 1px solid #cbd5e1;
  border-radius: 10px;
}

input:focus,
select:focus,
button:focus-visible {
  outline: 3px solid rgba(59, 130, 246, 0.35);
  outline-offset: 2px;
}

What changes around the fields?

  • The labels place their captions above the controls with consistent spacing.
  • Each control gets a minimum height and visible border for easier scanning.
  • The focus outline makes keyboard position visible across inputs, selects, and buttons.
  • Add the submit button styles directly below the focus rules using this code:
.primary-button {
  min-height: 44px;
  padding: 10px 18px;
  color: #ffffff;
  font-weight: 800;
  background: #4f46e5;
  border: 0;
  border-radius: 10px;
}

.primary-button:hover {
  background: #3730a3;
}

.filter-control {
  width: 180px;
}

What does the button styling add?

The primary button uses the dashboard's indigo palette and keeps the same height as the fields. Its darker hover state provides immediate visual feedback.

  • Save src/styles.css.
  • Return to the browser to inspect the finished form styling.

You should see bordered white controls with clear labels and a purple submit button. Pressing Tab should move a visible blue focus outline through the form.

  • Switch back to the form submit handler in src/App.tsx.
  • Insert the record creation logic directly below event.preventDefault() using this code:
            if (!company.trim() || !role.trim() || !date) {
              return
            }

            const newApplication: Application = {
              id: String(Date.now()),
              company: company.trim(),
              role: role.trim(),
              date,
              stage,
            }

            setApplications((currentApplications) => [
              newApplication,
              ...currentApplications,
            ])
            setCompany('')
            setRole('')
            setDate('')
            setStage('Applied')

What does the submit handler do?

  • The first check stops blank or incomplete records.
  • The newApplication object matches the existing Application type.
  • The state updater prepends the new record without changing the existing array.
  • The four setters reset the form after a successful submission.
  • Save src/App.tsx.
  • Enter Summit Works in the Company field.
  • Enter Frontend Engineer in the Role field.
  • Select 2026-10-18 in the Application date field.
  • Keep Applied selected in the Stage field.
  • Click Add application.

Your new Summit Works row should appear at the top of the table. The Total card should show 4 while the Active card shows 3.

That is the first interactive workflow working. The form now creates a typed record and clears itself for the next application.

New row not appearing?

  • Confirm every required form field contains a value.
  • Check that newApplication appears before the ...currentApplications spread.
  • Confirm the submit button still has type="submit".

Ask for help with a form submission that does not add a row.

✔️ Awesome, I've got everything!

Your form is styled and submitting records. Double check that src/styles.css is saved before continuing.

ⓧ I'd like to double check the full code

Compare your src/styles.css file with this complete version:

:root {
  font-family:
    Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
    sans-serif;
  color: #172033;
  background: #eef2ff;
  font-synthesis: none;
  text-rendering: optimizeLegibility;
}

* {
  box-sizing: border-box;
}

body {
  margin: 0;
  min-width: 320px;
  min-height: 100vh;
  background:
    radial-gradient(circle at top left, #dbeafe 0, transparent 34%),
    #f8fafc;
}

button,
input,
select {
  font: inherit;
}

button,
select {
  cursor: pointer;
}

.app-shell {
  width: min(1120px, calc(100% - 32px));
  margin: 0 auto;
  padding: 48px 0 64px;
}

.hero {
  padding: 36px;
  color: #ffffff;
  background: linear-gradient(135deg, #312e81, #2563eb);
  border-radius: 24px;
  box-shadow: 0 24px 55px rgba(49, 46, 129, 0.22);
}

.hero h1,
.section-heading h2 {
  margin: 0;
}

.hero h1 {
  max-width: 700px;
  font-size: clamp(2rem, 5vw, 3.75rem);
  line-height: 1.05;
}

.hero > p:last-child {
  max-width: 620px;
  margin: 16px 0 0;
  color: #dbeafe;
  font-size: 1.05rem;
}

.eyebrow {
  margin: 0 0 8px;
  color: #93c5fd;
  font-size: 0.78rem;
  font-weight: 800;
  letter-spacing: 0.14em;
  text-transform: uppercase;
}

.panel .eyebrow {
  color: #4f46e5;
}

.summary-grid {
  display: grid;
  grid-template-columns: repeat(4, 1fr);
  gap: 16px;
  margin: 20px 0;
}

.summary-card,
.panel {
  background: #ffffff;
  border: 1px solid #e2e8f0;
  box-shadow: 0 14px 35px rgba(15, 23, 42, 0.07);
}

.summary-card {
  display: flex;
  align-items: center;
  justify-content: space-between;
  min-height: 104px;
  padding: 22px;
  border-radius: 18px;
}

.summary-card span {
  color: #64748b;
  font-weight: 700;
}

.summary-card strong {
  color: #312e81;
  font-size: 2rem;
}

.panel {
  margin-top: 20px;
  padding: 28px;
  border-radius: 20px;
}

.section-heading,
.list-heading {
  display: flex;
  align-items: end;
  justify-content: space-between;
  gap: 20px;
  margin-bottom: 22px;
}

.application-form {
  display: grid;
  grid-template-columns: 1.2fr 1.4fr 1fr 1fr auto;
  align-items: end;
  gap: 14px;
}

.application-form label,
.filter-control {
  display: grid;
  gap: 7px;
  color: #475569;
  font-size: 0.86rem;
  font-weight: 750;
}

input,
select {
  width: 100%;
  min-height: 44px;
  padding: 10px 12px;
  color: #172033;
  background: #ffffff;
  border: 1px solid #cbd5e1;
  border-radius: 10px;
}

input:focus,
select:focus,
button:focus-visible {
  outline: 3px solid rgba(59, 130, 246, 0.35);
  outline-offset: 2px;
}

.primary-button {
  min-height: 44px;
  padding: 10px 18px;
  color: #ffffff;
  font-weight: 800;
  background: #4f46e5;
  border: 0;
  border-radius: 10px;
}

.primary-button:hover {
  background: #3730a3;
}

.filter-control {
  width: 180px;
}

.table-wrap {
  overflow-x: auto;
}

table {
  width: 100%;
  border-collapse: collapse;
}

th,
td {
  padding: 15px 12px;
  text-align: left;
  border-bottom: 1px solid #e2e8f0;
}

th {
  color: #64748b;
  font-size: 0.78rem;
  letter-spacing: 0.06em;
  text-transform: uppercase;
}

.stage-select {
  min-width: 118px;
  min-height: 38px;
  padding: 7px 10px;
  font-weight: 800;
  border: 0;
}

.stage-applied {
  color: #1d4ed8;
  background: #dbeafe;
}

.stage-interview {
  color: #7c3aed;
  background: #ede9fe;
}

.stage-offer {
  color: #047857;
  background: #d1fae5;
}

.stage-rejected {
  color: #b91c1c;
  background: #fee2e2;
}

.text-button {
  padding: 7px 0;
  color: #b91c1c;
  font-weight: 800;
  background: transparent;
  border: 0;
}

.empty-state {
  padding: 32px;
  color: #64748b;
  text-align: center;
}

@media (max-width: 900px) {
  .summary-grid {
    grid-template-columns: repeat(2, 1fr);
  }

  .application-form {
    grid-template-columns: repeat(2, 1fr);
  }
}

@media (max-width: 640px) {
  .app-shell {
    width: min(100% - 20px, 1120px);
    padding-top: 18px;
  }

  .hero,
  .panel {
    padding: 22px;
    border-radius: 16px;
  }

  .summary-grid,
  .application-form {
    grid-template-columns: 1fr;
  }

  .list-heading {
    align-items: stretch;
    flex-direction: column;
  }

  .filter-control {
    width: 100%;
  }

  thead {
    position: absolute;
    width: 1px;
    height: 1px;
    padding: 0;
    margin: -1px;
    overflow: hidden;
    clip: rect(0, 0, 0, 0);
    white-space: nowrap;
    border: 0;
  }

  tr {
    display: grid;
    gap: 10px;
    padding: 16px 0;
    border-bottom: 1px solid #e2e8f0;
  }

  td {
    display: grid;
    grid-template-columns: 105px 1fr;
    align-items: center;
    padding: 0;
    border: 0;
  }

  td::before {
    content: attr(data-label);
    color: #64748b;
    font-size: 0.78rem;
    font-weight: 800;
    text-transform: uppercase;
  }

  .empty-state {
    display: block;
  }

  .empty-state::before {
    content: none;
  }
}

What should match?

Your file should include the form grid, shared control styles, primary button, table styles, stage colors, and both responsive breakpoints. This complete stylesheet supports the interactive tracker at wide and narrow browser sizes.

Make each table row interactive

The form can add records, but each existing row still displays a fixed stage. Immutable updates let you replace one matching record while leaving every other record unchanged.

  • Switch back to src/App.tsx.
  • Find the existing offerCount calculation.
  • Add the stage update function directly below that calculation using this code:
  function updateStage(id: string, nextStage: Stage) {
    setApplications((currentApplications) =>
      currentApplications.map((application) =>
        application.id === id
          ? { ...application, stage: nextStage }
          : application,
      ),
    )
  }

How does the stage update work?

  • The function receives the record ID and its next typed stage.
  • The map() call creates a new array for React state.
  • The matching record gets a copied object with its stage replaced.
  • Every record with a different ID passes through unchanged.
  • Find the table cell that currently displays each application's stage.
  • Replace that cell with this interactive stage control:
                    <td data-label="Stage">
                      <select
                        className={`stage-select stage-${application.stage.toLowerCase()}`}
                        aria-label={`Update stage for ${application.company}`}
                        value={application.stage}
                        onChange={(event) =>
                          updateStage(
                            application.id,
                            event.target.value as Stage,
                          )
                        }
                      >
                        {stageOptions.map((option) => (
                          <option key={option} value={option}>
                            {option}
                          </option>
                        ))}
                      </select>
                    </td>

What connects the select to the row?

Each select reads its value from the current record. A change passes that record's ID and the selected stage to updateStage().

The stage name also selects a matching color class. The accessible label identifies which company's stage the control updates.

  • Save src/App.tsx.
  • Return to the browser.
  • Change Harbor Systems from Offer to Interview.

The Harbor Systems stage control should turn purple. The Active card should show 4 while Interviews shows 2 and Offers shows 0.

  • Return to src/App.tsx.
  • Add the removal function directly below updateStage() using this code:
  function removeApplication(id: string) {
    setApplications((currentApplications) =>
      currentApplications.filter((application) => application.id !== id),
    )
  }

How does removal stay immutable?

The filter() call creates a new array containing every record except the matching ID. React receives a new reference and recalculates the dashboard from the remaining records.

  • Find the Action cell inside each mapped table row.
  • Replace that cell with this remove button:
                    <td data-label="Action">
                      <button
                        className="text-button"
                        type="button"
                        onClick={() => removeApplication(application.id)}
                      >
                        Remove
                      </button>
                    </td>

What does the remove button pass?

Each button closes over the ID for its own row. Clicking it calls removeApplication() with that exact identifier.

  • Save src/App.tsx.
  • Return to the browser.
  • Click Remove in the Summit Works row.

The Summit Works row should disappear. The Total card should return to 3 while the other cards recalculate from the remaining records.

Stage or remove controls not responding?

  • Confirm each stage control passes application.id to updateStage().
  • Confirm each remove button passes the same row ID to removeApplication().
  • Check that both functions call setApplications with an updater function.

Ask for help with an interactive table row that does not update.

✔️ Awesome, I've got everything!

Your form and table now share the same in-memory application state. Save src/App.tsx before testing the refresh behavior.

ⓧ I'd like to double check the full code

Compare your src/App.tsx file with this complete in-memory version:

import { useState } from 'react'

type Stage = 'Applied' | 'Interview' | 'Offer' | 'Rejected'
type StageFilter = 'All' | Stage

type Application = {
  id: string
  company: string
  role: string
  date: string
  stage: Stage
}

const stageOptions: Stage[] = ['Applied', 'Interview', 'Offer', 'Rejected']

const starterApplications: Application[] = [
  {
    id: 'starter-1',
    company: 'Northstar Labs',
    role: 'Frontend Developer',
    date: '2026-10-08',
    stage: 'Applied',
  },
  {
    id: 'starter-2',
    company: 'BrightPath Studio',
    role: 'Junior Software Engineer',
    date: '2026-10-11',
    stage: 'Interview',
  },
  {
    id: 'starter-3',
    company: 'Harbor Systems',
    role: 'UI Engineer',
    date: '2026-10-14',
    stage: 'Offer',
  },
]

export default function App() {
  const [applications, setApplications] = useState<Application[]>(starterApplications)
  const [company, setCompany] = useState('')
  const [role, setRole] = useState('')
  const [date, setDate] = useState('')
  const [stage, setStage] = useState<Stage>('Applied')

  const activeCount = applications.filter(
    (application) =>
      application.stage === 'Applied' || application.stage === 'Interview',
  ).length
  const interviewCount = applications.filter(
    (application) => application.stage === 'Interview',
  ).length
  const offerCount = applications.filter(
    (application) => application.stage === 'Offer',
  ).length

  function updateStage(id: string, nextStage: Stage) {
    setApplications((currentApplications) =>
      currentApplications.map((application) =>
        application.id === id
          ? { ...application, stage: nextStage }
          : application,
      ),
    )
  }

  function removeApplication(id: string) {
    setApplications((currentApplications) =>
      currentApplications.filter((application) => application.id !== id),
    )
  }

  return (
    <main className="app-shell">
      <header className="hero">
        <p className="eyebrow">Career dashboard</p>
        <h1>Job Application Tracker</h1>
        <p>Track every opportunity from first application to final decision.</p>
      </header>

      <section className="summary-grid" aria-label="Application summary">
        <article className="summary-card">
          <span>Total</span>
          <strong>{applications.length}</strong>
        </article>
        <article className="summary-card">
          <span>Active</span>
          <strong>{activeCount}</strong>
        </article>
        <article className="summary-card">
          <span>Interviews</span>
          <strong>{interviewCount}</strong>
        </article>
        <article className="summary-card">
          <span>Offers</span>
          <strong>{offerCount}</strong>
        </article>
      </section>

      <section className="panel">
        <div className="section-heading">
          <div>
            <p className="eyebrow">New opportunity</p>
            <h2>Add an application</h2>
          </div>
        </div>

        <form
          className="application-form"
          onSubmit={(event) => {
            event.preventDefault()

            if (!company.trim() || !role.trim() || !date) {
              return
            }

            const newApplication: Application = {
              id: String(Date.now()),
              company: company.trim(),
              role: role.trim(),
              date,
              stage,
            }

            setApplications((currentApplications) => [
              newApplication,
              ...currentApplications,
            ])
            setCompany('')
            setRole('')
            setDate('')
            setStage('Applied')
          }}
        >
          <label>
            Company
            <input
              value={company}
              onChange={(event) => setCompany(event.target.value)}
              placeholder="Example Company"
              required
            />
          </label>
          <label>
            Role
            <input
              value={role}
              onChange={(event) => setRole(event.target.value)}
              placeholder="Frontend Developer"
              required
            />
          </label>
          <label>
            Application date
            <input
              type="date"
              value={date}
              onChange={(event) => setDate(event.target.value)}
              required
            />
          </label>
          <label>
            Stage
            <select
              value={stage}
              onChange={(event) => setStage(event.target.value as Stage)}
            >
              {stageOptions.map((option) => (
                <option key={option} value={option}>
                  {option}
                </option>
              ))}
            </select>
          </label>
          <button className="primary-button" type="submit">
            Add application
          </button>
        </form>
      </section>

      <section className="panel">
        <div className="section-heading list-heading">
          <div>
            <p className="eyebrow">Pipeline</p>
            <h2>Your applications</h2>
          </div>
        </div>

        <div className="table-wrap">
          <table>
            <thead>
              <tr>
                <th>Company</th>
                <th>Role</th>
                <th>Applied</th>
                <th>Stage</th>
                <th>Action</th>
              </tr>
            </thead>
            <tbody>
              {applications.map((application) => (
                <tr key={application.id}>
                  <td data-label="Company">{application.company}</td>
                  <td data-label="Role">{application.role}</td>
                  <td data-label="Applied">{application.date}</td>
                  <td data-label="Stage">
                    <select
                      className={`stage-select stage-${application.stage.toLowerCase()}`}
                      aria-label={`Update stage for ${application.company}`}
                      value={application.stage}
                      onChange={(event) =>
                        updateStage(
                          application.id,
                          event.target.value as Stage,
                        )
                      }
                    >
                      {stageOptions.map((option) => (
                        <option key={option} value={option}>
                          {option}
                        </option>
                      ))}
                    </select>
                  </td>
                  <td data-label="Action">
                    <button
                      className="text-button"
                      type="button"
                      onClick={() => removeApplication(application.id)}
                    >
                      Remove
                    </button>
                  </td>
                </tr>
              ))}
            </tbody>
          </table>
        </div>
      </section>
    </main>
  )
}

What should this version contain?

This version contains controlled form state, typed record creation, immutable stage updates, immutable removal, and live dashboard counts. It keeps every change in the current React state.

Test what survives a refresh

The tracker can now complete its main add and edit workflow. A browser refresh gives you a direct test of whether the current state has a life beyond this running page.

  • Enter Apex Digital in the Company field.
  • Enter React Developer in the Role field.
  • Select 2026-10-18 in the Application date field.
  • Keep Applied selected in the Stage field.
  • Click Add application.
  • Change the new Apex Digital row from Applied to Offer.

You should see the Apex Digital row at the top of the table with a green stage control. The summary cards should show Total 4, Active 3, Interviews 2, and Offers 1.

Before you refresh, do you expect Apex Digital and the Harbor Systems stage change to remain on the page?

  • Refresh http://localhost:5173 in your browser.

You should see the Apex Digital row disappear. Harbor Systems should return to Offer while the summary cards return to Total 3, Active 2, Interviews 1, and Offers 1.

Why did the changes disappear?

The page created every change inside the current in-memory applications state. Refreshing starts the component again with starterApplications.

This shortfall is the intended result. You have now reproduced the persistence problem from the user's point of view.

Added row still visible after refresh?

  • Confirm the browser completed a full refresh of http://localhost:5173.
  • Check that applications still initializes directly from starterApplications.
  • Confirm src/App.tsx does not read previously saved application data.

Ask for help with an in-memory record that unexpectedly survives refresh.

You have built the full in-memory workflow and exposed its most important limitation. Next, you will make records survive refreshes and add a stage filter for larger application lists.

Persist and Filter the Tracker

Your tracker now adds records through React state. The refresh test exposed the remaining problem. A browser reload erased every in-memory change.

This step synchronizes those records with browser storage. You will also derive a filtered view that keeps larger application lists manageable.

In this step, get ready to:
  • Synchronize application records with browser storage.
  • Derive a visible stage filter from the current records.
  • Verify the production build through Vite preview.
Persist applications in browser storage

The browser's localStorage saves text under a key for one origin. A lazy state initializer reads that saved text when the component starts.

  • In src/App.tsx, replace the opening section from the React import through the line before stageOptions with this code:
import { useEffect, useState } from 'react'

type Stage = 'Applied' | 'Interview' | 'Offer' | 'Rejected'
type StageFilter = 'All' | Stage

type Application = {
  id: string
  company: string
  role: string
  date: string
  stage: Stage
}

const STORAGE_KEY = 'job-application-tracker'

What does this code prepare?

  • The useEffect import provides the synchronization point for browser storage.
  • The STORAGE_KEY constant gives this tracker a stable storage address.
  • The existing types continue to protect every application record.
  • Find the state declarations that start with applications and end with stage.
  • Replace that state block with this storage-backed version:
  const [applications, setApplications] = useState<Application[]>(() => {
    const savedApplications = window.localStorage.getItem(STORAGE_KEY)
    return savedApplications
      ? (JSON.parse(savedApplications) as Application[])
      : starterApplications
  })
  const [company, setCompany] = useState('')
  const [role, setRole] = useState('')
  const [date, setDate] = useState('')
  const [stage, setStage] = useState<Stage>('Applied')
  const [stageFilter, setStageFilter] = useState<StageFilter>('All')

  useEffect(() => {
    window.localStorage.setItem(STORAGE_KEY, JSON.stringify(applications))
  }, [applications])

How does persistence work?

  • The lazy initializer asks window.localStorage.getItem(STORAGE_KEY) for an earlier saved list.
  • The initializer converts saved JSON text back into an application array.
  • The initializer falls back to starterApplications when the browser has no saved list.
  • The useEffect block saves a fresh copy whenever applications changes.
  • The stageFilter state starts at All for the filtering work ahead.
  • Save src/App.tsx.
  • Return to the tracker at http://localhost:5173.
  • Enter Pinecone Studio in the Company field.
  • Enter Frontend Engineer in the Role field.
  • Enter 2026-10-18 in the Application date field.
  • Select Applied in the Stage field.
  • Click Add application.

Before you refresh, do you think the new row will survive the same test that erased it in the previous step?

  • Refresh the browser page.

You should still see Pinecone Studio in the table. That closes the data-loss gap you exposed in the previous step.

Did the new row disappear?

Confirm that both storage blocks are inside the App component. Check that STORAGE_KEY uses the same spelling in every location.

Make sure you refreshed the same http://localhost:5173 page where you added the record.

help me debug localStorage persistence

Derive and display the stage filter

A filtered list is derived data because React can calculate it from the application list and the selected stage. Keeping one source array prevents the visible rows from drifting away from the dashboard counts.

  • In src/App.tsx, find the offerCount calculation.
  • Add the filtered application calculation directly below it:
  const filteredApplications = applications.filter(
    (application) =>
      stageFilter === 'All' || application.stage === stageFilter,
  )

What does the filter calculate?

  • The All option keeps every application in the derived array.
  • A specific stage keeps only records whose application.stage matches stageFilter.
  • The original applications array remains the single source of truth.
  • Find the heading area inside the Pipeline section.
  • Replace that heading area with this version:
        <div className="section-heading list-heading">
          <div>
            <p className="eyebrow">Pipeline</p>
            <h2>Your applications</h2>
          </div>
          <label className="filter-control">
            Filter by stage
            <select
              value={stageFilter}
              onChange={(event) =>
                setStageFilter(event.target.value as StageFilter)
              }
            >
              <option value="All">All</option>
              {stageOptions.map((option) => (
                <option key={option} value={option}>
                  {option}
                </option>
              ))}
            </select>
          </label>
        </div>

How does the control stay synchronized?

  • The select reads its current value from stageFilter.
  • The change handler sends the selected value through setStageFilter.
  • The options reuse stageOptions so the form and filter offer the same hiring stages.
  • Save src/App.tsx.
  • Return to the local tracker in your browser.

You should see a Filter by stage selector beside the application table heading.

Is the filter control missing?

Check that the replacement sits inside the second panel section. Confirm that you saved src/App.tsx after the edit.

help me place the stage filter

Connect the filtered rows and preview the build

The table still starts from the complete application array. Its final rendering condition uses filteredApplications and shows a clear empty state when no records match.

The table condition has an opening edit and a closing edit. Complete both edits before saving src/App.tsx.

  • Find the mapped table rows that currently start from applications.map((application) => (.
  • Replace that opening line with this conditional opening:
              {filteredApplications.length > 0 ? (
                filteredApplications.map((application) => (

What does the condition check?

The condition checks whether the derived array contains at least one row. Matching records continue into the existing table-row mapping.

  • Find the map closing immediately before </tbody>.
  • Replace that closing with this completed condition:
                ))
              ) : (
                <tr>
                  <td className="empty-state" colSpan={5}>
                    No applications match this stage.
                  </td>
                </tr>
              )}

What does the empty branch show?

The second branch spans one message across all five table columns. This keeps the table understandable when a stage has no matching applications.

  • Save src/App.tsx.
  • Return to the tracker at http://localhost:5173.
  • Select Rejected from the Filter by stage control.

You should see No applications match this stage. across the table. Select All to restore every saved row.

Are all rows still visible?

Confirm that the table maps filteredApplications inside the new condition. Check that the select updates stageFilter.

help me debug the table filter

✔️ Awesome, I've got everything!

Great. Double check that src/App.tsx is saved before you build the production version.

ⓧ I'd like to double check the full code

Compare your complete src/App.tsx file with this version:

import { useEffect, useState } from 'react'

type Stage = 'Applied' | 'Interview' | 'Offer' | 'Rejected'
type StageFilter = 'All' | Stage

type Application = {
  id: string
  company: string
  role: string
  date: string
  stage: Stage
}

const STORAGE_KEY = 'job-application-tracker'
const stageOptions: Stage[] = ['Applied', 'Interview', 'Offer', 'Rejected']

const starterApplications: Application[] = [
  {
    id: 'starter-1',
    company: 'Northstar Labs',
    role: 'Frontend Developer',
    date: '2026-10-08',
    stage: 'Applied',
  },
  {
    id: 'starter-2',
    company: 'BrightPath Studio',
    role: 'Junior Software Engineer',
    date: '2026-10-11',
    stage: 'Interview',
  },
  {
    id: 'starter-3',
    company: 'Harbor Systems',
    role: 'UI Engineer',
    date: '2026-10-14',
    stage: 'Offer',
  },
]

export default function App() {
  const [applications, setApplications] = useState<Application[]>(() => {
    const savedApplications = window.localStorage.getItem(STORAGE_KEY)
    return savedApplications
      ? (JSON.parse(savedApplications) as Application[])
      : starterApplications
  })
  const [company, setCompany] = useState('')
  const [role, setRole] = useState('')
  const [date, setDate] = useState('')
  const [stage, setStage] = useState<Stage>('Applied')
  const [stageFilter, setStageFilter] = useState<StageFilter>('All')

  useEffect(() => {
    window.localStorage.setItem(STORAGE_KEY, JSON.stringify(applications))
  }, [applications])

  const activeCount = applications.filter(
    (application) =>
      application.stage === 'Applied' || application.stage === 'Interview',
  ).length
  const interviewCount = applications.filter(
    (application) => application.stage === 'Interview',
  ).length
  const offerCount = applications.filter(
    (application) => application.stage === 'Offer',
  ).length
  const filteredApplications = applications.filter(
    (application) =>
      stageFilter === 'All' || application.stage === stageFilter,
  )

  function updateStage(id: string, nextStage: Stage) {
    setApplications((currentApplications) =>
      currentApplications.map((application) =>
        application.id === id
          ? { ...application, stage: nextStage }
          : application,
      ),
    )
  }

  function removeApplication(id: string) {
    setApplications((currentApplications) =>
      currentApplications.filter((application) => application.id !== id),
    )
  }

  return (
    <main className="app-shell">
      <header className="hero">
        <p className="eyebrow">Career dashboard</p>
        <h1>Job Application Tracker</h1>
        <p>Track every opportunity from first application to final decision.</p>
      </header>

      <section className="summary-grid" aria-label="Application summary">
        <article className="summary-card">
          <span>Total</span>
          <strong>{applications.length}</strong>
        </article>
        <article className="summary-card">
          <span>Active</span>
          <strong>{activeCount}</strong>
        </article>
        <article className="summary-card">
          <span>Interviews</span>
          <strong>{interviewCount}</strong>
        </article>
        <article className="summary-card">
          <span>Offers</span>
          <strong>{offerCount}</strong>
        </article>
      </section>

      <section className="panel">
        <div className="section-heading">
          <div>
            <p className="eyebrow">New opportunity</p>
            <h2>Add an application</h2>
          </div>
        </div>

        <form
          className="application-form"
          onSubmit={(event) => {
            event.preventDefault()

            if (!company.trim() || !role.trim() || !date) {
              return
            }

            const newApplication: Application = {
              id: String(Date.now()),
              company: company.trim(),
              role: role.trim(),
              date,
              stage,
            }

            setApplications((currentApplications) => [
              newApplication,
              ...currentApplications,
            ])
            setCompany('')
            setRole('')
            setDate('')
            setStage('Applied')
          }}
        >
          <label>
            Company
            <input
              value={company}
              onChange={(event) => setCompany(event.target.value)}
              placeholder="Example Company"
              required
            />
          </label>
          <label>
            Role
            <input
              value={role}
              onChange={(event) => setRole(event.target.value)}
              placeholder="Frontend Developer"
              required
            />
          </label>
          <label>
            Application date
            <input
              type="date"
              value={date}
              onChange={(event) => setDate(event.target.value)}
              required
            />
          </label>
          <label>
            Stage
            <select
              value={stage}
              onChange={(event) => setStage(event.target.value as Stage)}
            >
              {stageOptions.map((option) => (
                <option key={option} value={option}>
                  {option}
                </option>
              ))}
            </select>
          </label>
          <button className="primary-button" type="submit">
            Add application
          </button>
        </form>
      </section>

      <section className="panel">
        <div className="section-heading list-heading">
          <div>
            <p className="eyebrow">Pipeline</p>
            <h2>Your applications</h2>
          </div>
          <label className="filter-control">
            Filter by stage
            <select
              value={stageFilter}
              onChange={(event) =>
                setStageFilter(event.target.value as StageFilter)
              }
            >
              <option value="All">All</option>
              {stageOptions.map((option) => (
                <option key={option} value={option}>
                  {option}
                </option>
              ))}
            </select>
          </label>
        </div>

        <div className="table-wrap">
          <table>
            <thead>
              <tr>
                <th>Company</th>
                <th>Role</th>
                <th>Applied</th>
                <th>Stage</th>
                <th>Action</th>
              </tr>
            </thead>
            <tbody>
              {filteredApplications.length > 0 ? (
                filteredApplications.map((application) => (
                  <tr key={application.id}>
                    <td data-label="Company">{application.company}</td>
                    <td data-label="Role">{application.role}</td>
                    <td data-label="Applied">{application.date}</td>
                    <td data-label="Stage">
                      <select
                        className={`stage-select stage-${application.stage.toLowerCase()}`}
                        aria-label={`Update stage for ${application.company}`}
                        value={application.stage}
                        onChange={(event) =>
                          updateStage(
                            application.id,
                            event.target.value as Stage,
                          )
                        }
                      >
                        {stageOptions.map((option) => (
                          <option key={option} value={option}>
                            {option}
                          </option>
                        ))}
                      </select>
                    </td>
                    <td data-label="Action">
                      <button
                        className="text-button"
                        type="button"
                        onClick={() => removeApplication(application.id)}
                      >
                        Remove
                      </button>
                    </td>
                  </tr>
                ))
              ) : (
                <tr>
                  <td className="empty-state" colSpan={5}>
                    No applications match this stage.
                  </td>
                </tr>
              )}
            </tbody>
          </table>
        </div>
      </section>
    </main>
  )
}

A production build asks Vite to bundle the application. It also gives TypeScript a chance to catch mistakes before deployment.

  • Return to Visual Studio Code.
  • Create another PowerShell terminal from the terminal panel.

Before you build, do you expect every new storage expression and filter expression to pass the production checks?

  • Create the production build by running:
npm run build

What does the build create?

Vite checks the application and bundles its static production assets into dist. A successful build proves the new state logic compiles for deployment.

The terminal should report a successful build. The dist folder should now appear in the Visual Studio Code Explorer sidebar.

Did the production build fail?

Read the first file location reported by the build. Compare that part of src/App.tsx with the full-code tab above.

help me debug the production build

  • Start the production preview by running:
npm run preview

What does preview test?

The preview command serves the contents of dist at http://localhost:4173. This tests the same production bundle you will deploy later.

  • Open http://localhost:4173 in your browser.
  • Enter Summit Works in the Company field.
  • Enter React Developer in the Role field.
  • Enter 2026-10-18 in the Application date field.
  • Select Interview in the Stage field.
  • Click Add application.
  • Select Interview from the Filter by stage control.

Before you refresh, do you expect the new application to remain in the production preview?

  • Refresh the production preview page.

You should still see Summit Works in the application table. The filter returns to All because the app persists records only.

  • Select Interview from the Filter by stage control again.

You should see the saved Summit Works row with the other interview records. You now have a production-built tracker that survives refreshes and narrows its table by hiring stage.

Is the preview page unavailable?

Confirm that the preview terminal is still running. Check that your browser uses port 4173 for this test.

help me troubleshoot Vite preview

Your tracker now keeps application records and turns a growing pipeline into a focused view. Next up, you will package this working production build for a public GitHub Pages URL.

Deploy the App to GitHub Pages

Your React tracker now keeps application records across browser refreshes. The local Vite preview proves that the production build works on your computer.

A local project cannot be reviewed by an employer. GitHub Pages gives the tracker a public URL. GitHub Actions rebuilds the static site after Git pushes reach main.

In this step, get ready to:
  • Configure the production path and deployment workflow.
  • Publish the project to a public GitHub repository.
  • Deploy the tracker and test its public URL.
Configure the production deployment

A GitHub Pages project site lives under its repository path. Vite needs that path before it can generate working asset URLs for the deployed tracker.

  • In the VS Code Explorer sidebar from earlier, select vite.config.js.
  • Find this current configuration:
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'

export default defineConfig({
  plugins: [react()],
})

What are you looking at?

This configuration enables the React plugin for Vite. It currently assumes the app is served from the root of a website.

  • Replace the current configuration with this deployment-ready version:
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'

export default defineConfig({
  plugins: [react()],
  base: '/job-application-tracker/',
})

What does the base setting do?

The base value tells Vite that the deployed app lives beneath /job-application-tracker/. Generated asset URLs now include the repository path.

  • Save vite.config.js.
  • Switch back to the VS Code PowerShell terminal from earlier.
  • Stop the preview server with Ctrl+C if it is still running.
  • Rebuild the tracker with its deployment path by running:
npm run build

What does this build prove?

Vite compiles the tracker into static production assets. A successful build places those files in dist.

You should see the production build complete without errors. The dist folder is regenerated with assets that use the repository path.

Build failing after the config change?

Check that base sits inside the object passed to defineConfig(). Confirm that the line ends with a comma.

Use help me debug the Vite configuration if the build still fails.

✔️ Awesome, I've got everything!

Your Vite configuration now builds assets for the GitHub Pages repository path.

ⓧ I'd like to double check the full code

Compare your complete vite.config.js file with this reference:

import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'

export default defineConfig({
  plugins: [react()],
  base: '/job-application-tracker/',
})

What should match?

The file keeps the React plugin configuration from earlier. The repository path is the only new setting.

The deployment workflow describes how GitHub rebuilds the project. It also grants only the permissions required to publish the resulting static files.

  • Select the job-application-tracker folder in the VS Code Explorer sidebar.
  • Click the New Folder icon at the top of the Explorer sidebar.
  • Enter .github as the folder name.
  • Select the new .github folder.
  • Click the New Folder icon again.
  • Enter workflows as the nested folder name.

You should see a .github/workflows path inside the project.

  • Select the workflows folder.
  • Click the New File icon at the top of the Explorer sidebar.
  • Enter deploy.yml as the file name.

You should see .github/workflows/deploy.yml open in the editor.

  • Add the workflow trigger and deployment job structure by pasting this first section into deploy.yml:
name: Deploy static content to Pages

on:
  push:
    branches: ['main']
  workflow_dispatch:

permissions:
  contents: read
  pages: write
  id-token: write

concurrency:
  group: 'pages'
  cancel-in-progress: true

jobs:
  deploy:
    environment:
      name: github-pages
      url: ${{ steps.deployment.outputs.page_url }}
    runs-on: ubuntu-latest
    steps:

What does this section control?

  • The push trigger starts a deployment when the main branch changes.
  • The workflow_dispatch trigger allows a manual workflow run.
  • The permissions allow the job to read repository files and publish GitHub Pages content.
  • The concurrency group prevents overlapping Pages deployments.
  • Save deploy.yml.
  • Confirm that the editor shows the workflow name and the deploy job.

Workflow file in the wrong place?

Confirm that deploy.yml appears beneath .github/workflows in the Explorer sidebar. A workflow stored elsewhere in the project does not run.

Use help me check the workflow path if the folders do not match.

  • Place your cursor on the line beneath steps:.
  • Add the workflow steps by pasting this section:
      - name: Checkout
        uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
      - name: Set up Node
        uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020
        with:
          node-version: lts/*
          cache: 'npm'
      - name: Install dependencies
        run: npm ci
      - name: Build
        run: npm run build
      - name: Setup Pages
        uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d
      - name: Upload artifact
        uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9
        with:
          path: './dist'
      - name: Deploy to GitHub Pages
        id: deployment
        uses: actions/deploy-pages@368f82528645a54fb793d4d04e342629a3f51346

How does the deployment run?

  • The checkout step makes the repository files available to the runner.
  • The Node setup step selects the LTS runtime and enables the npm cache.
  • The install step uses npm ci to reproduce the dependencies recorded in package-lock.json.
  • The build step creates the production files in dist.
  • The final steps upload those files and deploy them to GitHub Pages.
  • Save .github/workflows/deploy.yml.
  • Verify the project still builds with its complete deployment configuration by running:
npm run build

Why build again?

This local build runs the same project script used by the workflow. It catches application build problems before GitHub starts the deployment.

You should see the build finish successfully. The dist folder should exist again.

Production build failing now?

The workflow file does not change the local application build. Recheck vite.config.js if the error mentions the Vite configuration.

Use help me diagnose the production build if the source of the failure is unclear.

✔️ Awesome, I've got everything!

Your deployment workflow is saved. Your production build is ready to publish.

ⓧ I'd like to double check the full code

Compare your complete .github/workflows/deploy.yml file with this reference:

name: Deploy static content to Pages

on:
  push:
    branches: ['main']
  workflow_dispatch:

permissions:
  contents: read
  pages: write
  id-token: write

concurrency:
  group: 'pages'
  cancel-in-progress: true

jobs:
  deploy:
    environment:
      name: github-pages
      url: ${{ steps.deployment.outputs.page_url }}
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
      - name: Set up Node
        uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020
        with:
          node-version: lts/*
          cache: 'npm'
      - name: Install dependencies
        run: npm ci
      - name: Build
        run: npm run build
      - name: Setup Pages
        uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d
      - name: Upload artifact
        uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9
        with:
          path: './dist'
      - name: Deploy to GitHub Pages
        id: deployment
        uses: actions/deploy-pages@368f82528645a54fb793d4d04e342629a3f51346

What should match?

The complete file includes the triggers and permissions. It also contains one deployment job with checkout and build steps.

Publish the project to GitHub

A Git commit captures the complete tracker as a reproducible snapshot. The remote repository gives the workflow a place to run whenever main changes.

Keep the public demo safe

The repository and deployed site are public. Use fictional application records so personal applicant data never becomes part of the demo.

  • Return to your GitHub account from earlier.
  • Start GitHub's new repository form from the top navigation.
  • Enter job-application-tracker as the repository name.
  • Choose public visibility.
  • Leave the optional repository initialization choices empty.
  • Submit the form to create the repository.

Your empty public repository is ready. It now has an address for the local project.

  • Switch back to the VS Code PowerShell terminal.
  • Initialize the local repository with main as its first branch by running:
git init -b main

What does this command do?

The command turns job-application-tracker into a Git repository. It creates main as the initial branch.

  • Stage every project file and create the first snapshot by running:
git add .
git commit -m "Build job application tracker"

What does this snapshot contain?

The staging command selects the source files and package-lock.json. The commit stores that exact project state with the message Build job application tracker.

You should see a new commit created on main. Its summary should include the tracker files and deployment workflow.

Commit not created?

Git may ask for your author identity before it creates the first commit. Follow the guidance in the terminal before running the commit again.

Use help me finish my first Git commit if the terminal output is unclear.

  • Copy the HTTPS repository URL from the empty GitHub repository page.
  • Connect the local repository by replacing REMOTE-URL with the copied URL before running this command:
git remote add origin REMOTE-URL

What is origin?

The name origin identifies the GitHub repository as this project's remote destination. Future pushes can use that short name.

  • Verify the saved remote address by running:
git remote -v

What should the remote show?

The output should show the copied job-application-tracker repository URL. The same destination is used for retrieving and publishing commits.

Your first push may ask

GitHub may ask you to authenticate when you publish from this computer. Complete the browser or terminal sign-in prompt before returning to VS Code.

Before you push, predict whether GitHub will receive only the source files or the generated dist folder too.

  • Publish the committed main branch by running:
git push -u origin main

What did the push publish?

The push sends the committed source code and workflow to GitHub. The ignored dist folder stays local because the workflow builds a fresh copy during deployment.

The terminal should confirm that main now tracks the remote branch. Your GitHub repository should display the project files.

Push not reaching GitHub?

Confirm that git remote -v shows the public repository you created. Complete any GitHub authentication request before retrying the push.

Use help me troubleshoot the Git push if the push still fails.

Launch and test the live site

GitHub now has everything needed to build the tracker. The Pages setting chooses GitHub Actions as the source of each public deployment.

  • In the GitHub repository, open Settings.
  • Select Pages.
  • Under Build and deployment, set Source to GitHub Actions.
  • Open the repository's Actions tab.
  • Select the Deploy static content to Pages workflow run.

The deployment can take a little while on its first run. Keep the workflow page open until every job completes.

  • Wait for the deployment workflow to finish.

You should see the deployment complete successfully. This proves that GitHub installed the locked dependencies and built the static site.

Workflow did not complete?

Open the failed job to identify whether dependency installation or the production build stopped. Compare the repository versions of package-lock.json and vite.config.js with the files in VS Code.

Use help me debug the Pages workflow if you need help reading the failed job.

  • Return to Settings in the repository.
  • Select Pages again.
  • Record the published address here: https://YOUR_USERNAME.github.io/job-application-tracker.

Before you open the published address, predict whether the tracker will load with the same layout as the production preview.

  • Open https://YOUR_USERNAME.github.io/job-application-tracker in your browser.

You should see the Job Application Tracker header and the four summary cards. The application form and responsive table should appear beneath them.

  • Enter Example Company in the Company field.
  • Enter Frontend Developer in the Role field.
  • Select 2026-10-18 in the Application date field.
  • Click Add application.
  • Change the new row's stage to Interview.
  • Set Filter by stage to Interview.
  • Refresh the live page.
  • Set Filter by stage to Interview again.

You should still see the test record after the refresh. Its Interview stage should contribute to the dashboard count and remain available through the filter.

Live page missing or outdated?

Confirm that the Pages source uses GitHub Actions. Check that the latest deployment workflow completed after your push.

Use help me fix the live GitHub Pages site if the published site still does not match the local build.

That completes the deployment loop. Every future push to main can rebuild the public tracker automatically.

Secret mission

Create a Recruiter-Ready QA Report

Turn your live tracker into evidence of structured product testing. You will document a keyboard-only journey. You will also prove refresh persistence. A final test shows dashboard counts reacting to a stage change.

Clean Up Your Resources

Clean Up Your Resources

Your public GitHub repository and GitHub Pages deployment cost $0 while the repository remains public on GitHub Free. Choose whether to keep everything available, pause the public site, or delete the project entirely.

Keep Public Data Safe

Your source code is public. Your deployed tracker is public too.

  • Use fictional application records in public demos.
  • Keep personal applicant data out of the repository.
  • Keep passwords and API keys out of the repository.

Resources you used:

  • The local job-application-tracker folder with its node_modules and dist directories.
  • The public GitHub repository named job-application-tracker with its source code, dependency lockfile, deployment workflow, and QA report issue.
  • The public GitHub Pages deployment produced by GitHub Actions.

Keep everything running

No action is needed. Choose this option if you want the tracker to remain available as a live portfolio project.

  • Leave the public job-application-tracker repository unchanged.
  • Keep the GitHub Pages deployment published at its current URL.
  • Keep the QA report issue available for recruiters to review.

Future pushes to main trigger the deployment workflow. Your live site stays current without ongoing hosting costs.

Pause - I'll come back to this later

Pause the public site when you want to keep the repository without serving the tracker. Your source code and project history remain available.

  • Return to the job-application-tracker repository on GitHub.
  • Select Settings in the repository navigation.
  • Select Pages in the left sidebar.
  • Click Unpublish site to remove the current deployment.
  • Avoid pushing changes to main while the site is paused.

Your Pages URL stops serving the tracker. The repository and local project files remain available for your return.

Delete - I don't want to use this again

Deleting these resources is permanent. Check that you no longer need the live demo, QA report, or project history before you continue.

Delete the local copy
  • In the Explorer sidebar in Visual Studio Code, right-click the job-application-tracker folder.
  • Select the option that reveals the folder in Windows File Explorer.
  • Close Visual Studio Code.
  • Select the job-application-tracker folder in File Explorer.
  • Press Shift+Delete to permanently remove the local project.
Delete the hosted copy
  • Return to the job-application-tracker repository in your browser.
  • Select Settings in the repository navigation.
  • Select Pages in the left sidebar.
  • Click Unpublish site to remove the GitHub Pages deployment.
  • Select General in the repository settings.
  • Scroll to Danger Zone at the bottom of the page.
  • Click Delete this repository.
  • Complete GitHub's confirmation process to permanently delete the repository.

The public repository is removed with its workflow and QA report. The project URL no longer hosts your tracker.

Nice Work!

Nice Work!

You did it! Your responsive React and TypeScript job application tracker now runs from a public GitHub Pages URL with saved records and automatic deployment.

You've learned how to:

  • Build a responsive application tracker that adds records, updates hiring stages, removes entries, filters the table, and recalculates dashboard totals.
  • Model application records with explicit types while managing controlled inputs, immutable updates, and derived values through React state.
  • Fix refresh data loss with browser persistence before publishing the tracker through an automated GitHub Actions workflow.
  • Secret Mission: Create a recruiter-ready QA report that documents passed keyboard navigation, persistence, and stage-change tests in a GitHub issue.

Ready to quiz yourself?