AI Tester Blueprint Projects Job Tracker AI
Project
Project . AI Tester Blueprint . Job Tracker AI

Job Tracker AI: a small app worth testing

A Vite and React Kanban board for job applications. Every card lives in the browser's IndexedDB, so there is no backend, no login and no API key. Despite the name there is no AI or LLM code in it: treat it as a realistic small app to explore, break and automate. It has at least three real bugs, and this page shows where they are and how to prove them.

6
Status columns
Wishlist, Applied, Follow-up, Interview, Offer, Rejected.
4
Storage functions
getAllJobs, saveJob, deleteJob, replaceJobs in src/db.js.
0
Network calls
No backend, no auth, no env vars. The data stays in one browser.
3
Real bugs to reproduce
Two in the date maths, one import that crashes the board.

01What the app is, and why it is worth testing

Job Tracker AI is a single-page app for tracking job applications on a Kanban board. Cards move through six columns, and you can add, edit, delete, search, sort, drag between columns, and back the whole board up as JSON. The README is clear about the scope: "There is no backend, authentication, or external API call."

The name says AI, but there is no AI in the code: no model, no prompt, no network request. That makes it a good practice target for this course: small enough to read in one sitting, real enough to have real bugs. A tester's plan for it looks like this:

AreaWhat to testWhy it can break
StorageAdd, edit, move and delete survive a reload; a fresh browser profile starts emptyEvery change awaits an IndexedDB write before React state updates
ValidationRequired company, role and date; URL must be http or https; blank URL allowedRules live in validate() and isValidUrl() only
Dates"Today", "1 day", "Future", "No date" across time zones and daylight-saving changesUTC parsing and local date parts are mixed (bugs 1 and 2)
Drag and dropA drop on another column saves; a drop on the same column saves nothingdnd-kit starts a drag only after 8 px of pointer movement
BackupExport shape; import of good, partial and hostile JSON; the replace confirmationImported JSON is untrusted input that goes straight into storage (bug 3)
Search and sortCompany and role only, case-insensitive; newest or oldest by date appliedNotes, resume and salary are not searched
No tests ship with the project. There is no test folder and no test script in package.json. Everything on this page, from the bug reports to the Playwright locators, is a starting point for the suite it does not have yet.

02How it works

All data flows through one React component and one small storage module. On load, getAllJobs() reads every record; after that, each change is written to IndexedDB first and to React state second.

flowchart LR
  M["App mounts"] -->|"getAllJobs()"| DB[("IndexedDB: job-tracker-ai, store jobs")]
  DB -->|"setJobs"| S["jobs state"]
  S -->|"search: company or role"| F["filteredJobs"]
  F -->|"group by status, unknown to Wishlist"| G["6 columns"]
  G -->|"sort by dateApplied"| R["cards on the board"]
Load and render. Filtering, grouping and sorting are derived with useMemo, so the board recomputes only when the jobs, the search or the sort change.
sequenceDiagram
  actor U as Tester
  participant F as JobFormModal
  participant A as App
  participant D as db.js (IndexedDB)
  U->>F: fill the fields, press Save
  F->>F: validate(): company, role, URL, date
  F->>A: onSave(values)
  A->>D: saveJob(job), a put by id
  A->>A: setJobs, toast "Job added"
  U->>A: drag a card to another column
  A->>D: saveJob with the new status and updatedAt
  U->>A: Import JSON
  A->>A: normalizeImportedJobs()
  A->>U: window.confirm, replace the board?
  A->>D: replaceJobs(): clear, then put, in one transaction
The three write paths. Note the order on import: storage is replaced before the board renders the new data.

03Live demo: the board, its records and its bugs

This is the app's logic running on this page: the same columns, validation messages, grouping, sorting, export shape and import rules, ported from src/. Records stay in memory, and the clock and time zone are simulated so every run is repeatable. Add, edit, move (with the drop-down or by dragging), delete, export and import, and watch the stored record and the log. Then change the time zone to see bug 1, and import a status named constructor to see bug 3.

Job Tracker AI in the browser: board, records, backups and the date bugNo API key needed
data-testid=jobt-searchdata-testid=jobt-sortdata-testid=jobt-adddata-testid=jobt-add-applieddata-testid=jobt-totaldata-testid=jobt-todaydata-testid=jobt-timedata-testid=jobt-tzdata-testid=jobt-fixdata-testid=jobt-toast

Wishlist

0

Applied

0

Follow-up

0

Interview

0

Offer

0

Rejected

0
data-testid=jobt-import-textdata-testid=jobt-importdata-testid=jobt-import-replacedata-testid=jobt-exportdata-testid=jobt-crashdata-testid=jobt-clear
Export (the file the app would download)

    
Stored record (IndexedDB job-tracker-ai, store jobs)

      Event log
      
    What differs from the real app, on purpose: memory instead of IndexedDB, ids job-1, job-2... instead of crypto.randomUUID(), fixed time-zone offsets (so no daylight-saving changes), an inline confirmation instead of window.confirm, and a status line that keeps the last message (the app hides its toast after 2.6 seconds). The sample cards are fictional. "Apply the fixes" switches the date maths and the status lookup to the fixed versions in card 05.

    04The code that matters

    Five files in src/ plus build configuration. Almost all behaviour is in App.jsx; storage is 45 lines.

    FileWhat it holds
    src/App.jsx (820 lines)Everything else: date and URL helpers, normalizeImportedJobs, the App state, and the components JobColumn, JobCard, JobFormModal, Field, DeleteModal.
    src/db.jsThe IndexedDB layer through idb: getAllJobs, saveJob (put), deleteJob, replaceJobs (clear and put in one transaction).
    src/constants.jsSTATUS_COLUMNS with each column's Tailwind colours, STATUS_BY_ID, todayInputValue() and blankJobForm().
    src/main.jsx, src/index.cssMounts <App /> in React.StrictMode; Tailwind layers and base resets.
    package.jsonScripts dev (vite --host 127.0.0.1), build, preview. Runtime: React 18, @dnd-kit/core, idb, lucide-react.
    tailwind.config.js, vite.config.jsClass-based dark mode and a soft shadow; the React plugin and nothing else.
    src/db.js
    import { openDB } from "idb";
    
    const DB_NAME = "job-tracker-ai";
    const DB_VERSION = 1;
    const JOB_STORE = "jobs";
    
    const dbPromise = openDB(DB_NAME, DB_VERSION, {
      upgrade(db) {
        if (!db.objectStoreNames.contains(JOB_STORE)) {
          const store = db.createObjectStore(JOB_STORE, { keyPath: "id" });
          store.createIndex("status", "status");
          store.createIndex("company", "company");
          store.createIndex("role", "role");
        }
      },
    });
    
    export async function getAllJobs() {
      const db = await dbPromise;
      return db.getAll(JOB_STORE);
    }
    
    export async function saveJob(job) {
      const db = await dbPromise;
      await db.put(JOB_STORE, job);
      return job;
    }
    
    export async function deleteJob(id) {
      const db = await dbPromise;
      await db.delete(JOB_STORE, id);
    }
    
    export async function replaceJobs(jobs) {
      const db = await dbPromise;
      const tx = db.transaction(JOB_STORE, "readwrite");
      await tx.store.clear();
    
      for (const job of jobs) {
        await tx.store.put(job);
      }
    
      await tx.done;
      return jobs;
    }

    Two helpers decide what the cards say. daysSince turns a date into a label; normalizeImportedJobs cleans a backup before it replaces the board:

    src/App.jsx (lines 41-56)
    const daysSince = (value) => {
      const start = new Date(value);
      if (Number.isNaN(start.getTime())) {
        return "No date";
      }
    
      const today = new Date();
      const startDay = new Date(start.getFullYear(), start.getMonth(), start.getDate());
      const todayDay = new Date(today.getFullYear(), today.getMonth(), today.getDate());
      const diff = Math.floor((todayDay - startDay) / 86_400_000);
    
      if (diff < 0) return "Future";
      if (diff === 0) return "Today";
      if (diff === 1) return "1 day";
      return `${diff} days`;
    };
    src/App.jsx (lines 69-89)
    const normalizeImportedJobs = (items) =>
      items
        .filter((item) => item && String(item.company || "").trim() && String(item.role || "").trim())
        .map((item) => {
          const createdAt = item.createdAt || new Date().toISOString();
          const status = STATUS_BY_ID[item.status] ? item.status : "wishlist";
    
          return {
            id: item.id || makeId(),
            company: String(item.company || "").trim(),
            role: String(item.role || "").trim(),
            linkedinUrl: String(item.linkedinUrl || "").trim(),
            resume: String(item.resume || "").trim(),
            dateApplied: item.dateApplied || todayInputValue(),
            salary: String(item.salary || "").trim(),
            notes: String(item.notes || "").trim(),
            status,
            createdAt,
            updatedAt: item.updatedAt || createdAt,
          };
        });

    The board itself is derived state: filter, then group by status (unknown statuses fall back to Wishlist), then sort each column by date. A drop on a different column saves the new status; a drop on the same column returns early and saves nothing:

    src/App.jsx (lines 164-180)
      const jobsByStatus = useMemo(() => {
        const grouped = Object.fromEntries(STATUS_COLUMNS.map((column) => [column.id, []]));
    
        for (const job of filteredJobs) {
          const status = STATUS_BY_ID[job.status] ? job.status : "wishlist";
          grouped[status].push(job);
        }
    
        for (const column of STATUS_COLUMNS) {
          grouped[column.id].sort((a, b) => {
            const diff = dateToTime(a.dateApplied) - dateToTime(b.dateApplied);
            return sortDirection === "oldest" ? diff : -diff;
          });
        }
    
        return grouped;
      }, [filteredJobs, sortDirection]);
    src/App.jsx (lines 226-236)
      const handleDragEnd = async ({ active, over }) => {
        setActiveJob(null);
        if (!over || !STATUS_BY_ID[over.id]) return;
    
        const job = jobs.find((item) => item.id === active.id);
        if (!job || job.status === over.id) return;
    
        const updated = { ...job, status: over.id, updatedAt: new Date().toISOString() };
        await saveJob(updated);
        setJobs((current) => current.map((item) => (item.id === updated.id ? updated : item)));
      };

    05Three real bugs, and how to prove them

    Each of these was reproduced by running the app's own code in Node, the two date bugs with a frozen clock and a real TZ setting. All three need an environment a developer on a UTC laptop rarely has, which is exactly why a tester should look for them.

    Bug 1: the date is read as UTC and displayed as local

    todayInputValue() returns the UTC date (toISOString().slice(0, 10)), and new Date("2026-10-09") means midnight UTC, but daysSince then reads the local date parts. West of UTC every date is shown one day older; east of UTC, early in the morning, the form pre-fills yesterday.

    Browser time zone, local time on 9 Oct 2026Form pre-fillsCard for a job applied todayCard for 2026-10-02
    UTC, 10:002026-10-09Today7 days
    America/New_York, 10:002026-10-091 day8 days
    America/New_York, 21:002026-10-10 (tomorrow)"Today" for that date; 2026-10-09 shows "1 day"8 days
    Asia/Kolkata, 02:002026-10-08 (yesterday)"1 day" for that date; 2026-10-09 shows "Today"7 days

    Bug 2: a daylight-saving change drops a day

    daysSince divides the gap between two local midnights by 86,400,000 and floors it. A spring-forward day is 23 hours long, so the count loses one. In Europe/Berlin on 5 April 2027, a job applied on 2027-03-20 shows "15 days" instead of 16; 2027-03-28 and 2027-03-29 both show "7 days". The demo uses fixed offsets, so reproduce this one in Node or with Playwright's timezoneId.

    Bug 3: an import can crash the board for good

    STATUS_BY_ID is built with Object.fromEntries, a plain object, so STATUS_BY_ID["constructor"] is truthy. An imported job with status constructor, __proto__ or toString keeps that status, and grouping then throws TypeError: grouped[status].push is not a function. With no error boundary, React unmounts the page. replaceJobs() has already saved the import, so every reload crashes again until the site data is cleared.

    a possible fix (not in the repo)
    // A possible fix (not in the repo): treat "YYYY-MM-DD" as a calendar date everywhere.
    const pad = (n) => String(n).padStart(2, "0");
    
    export const todayInputValue = () => {
      const d = new Date();
      return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
    };
    
    const dayNumber = (y, m, d) => Date.UTC(y, m, d) / 86_400_000; // whole days, no DST
    
    const daysSince = (value) => {
      const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value || "");
      if (!match) return "No date";
      const today = new Date();
      const diff =
        dayNumber(today.getFullYear(), today.getMonth(), today.getDate()) -
        dayNumber(Number(match[1]), Number(match[2]) - 1, Number(match[3]));
    
      if (diff < 0) return "Future";
      if (diff === 0) return "Today";
      if (diff === 1) return "1 day";
      return `${diff} days`;
    };
    
    // Status lookups: only the six real columns count, never inherited keys.
    const isKnownStatus = (status) => Object.hasOwn(STATUS_BY_ID, status);

    Tested in Node under UTC, America/New_York, Asia/Kolkata and Europe/Berlin: today shows "Today" everywhere, the form pre-fills the local date, and the daylight-saving case returns 16 days. It accepts only YYYY-MM-DD, which is what the date input stores; normalise imported dates to that format as well.

    06Run it

    Node.js 20.19 or later (or 22.12 or later), as Vite 8 requires. No accounts, keys or environment variables.

    terminal
    cd Project_Job_TRACKERAI
    npm install
    npm run dev        # vite --host 127.0.0.1, open the URL it prints
    npm run build      # production build into dist/
    npm run preview    # serve the build on 127.0.0.1

    Data lives in the IndexedDB database job-tracker-ai (version 1, store jobs, key path id, indexes on status, company and role). The theme choice is kept in localStorage under job-tracker-theme; the first visit follows the operating system's colour scheme.

    07Automating the real app with Playwright

    The app has no test ids, but every control has a visible label, an aria-label or a placeholder, so role and label locators reach all of it. These come straight from the JSX:

    To reachLocator, from the source code
    Add a jobgetByRole('button', { name: 'Add Job' }), or { name: 'Add to Applied' } for a column
    Form fieldsgetByLabel('Company name'), getByLabel('Job title / role'), getByLabel('LinkedIn job URL'), getByLabel('Date applied'), getByLabel('Status')
    Save and cancelgetByRole('button', { name: 'Save' }), getByRole('button', { name: 'Cancel' })
    Search and sortgetByPlaceholder('Search company or role'), getByLabel('Sort cards')
    Card actionsgetByRole('button', { name: 'Edit job' }), { name: 'Delete job' }, link { name: 'Open LinkedIn job' }: scope to one card first
    Backup and themegetByRole('button', { name: 'Export JSON' }), { name: 'Import JSON' }, { name: 'Use dark mode' }
    MessagesgetByText('Job added'), getByText('No cards'), getByText('0 jobs')
    • Every test starts with an empty board. Each Playwright test gets a fresh browser context, so IndexedDB is empty: expect 0 jobs and "No cards" until the test adds data.
    • Import needs two things. The file input is hidden, so call setInputFiles on input[type=file]; and the app asks window.confirm, so register page.once('dialog', (d) => d.accept()) first.
    • Dragging needs real pointer movement. dnd-kit's PointerSensor starts a drag only after 8 px, so move the mouse in steps, for example page.mouse.move(x, y, { steps: 10 }), between mouse.down() and mouse.up().
    • Dates need a fixed clock and zone. Use test.use({ timezoneId: 'America/New_York' }) with page.clock.setFixedTime(...) to reproduce bugs 1 and 2 on demand.
    • Toasts vanish after 2.6 seconds. Assert them straight after the action with a web-first expect.

    08Gotchas

    • The data lives in one browser profile. A private window, another browser or cleared site data shows an empty board. Export JSON is the only backup.
    • Import replaces everything. After the confirmation, replaceJobs() clears the store and writes the backup in one transaction; there is no merge.
    • Bad rows disappear silently. Rows without a company or a role are dropped, unknown statuses become Wishlist (except the inherited names in bug 3), and missing ids, dates and timestamps are filled in.
    • A broken backup file shows the parser's own message. Invalid JSON is caught and its error.message is shown as the toast, so the wording depends on the browser, not on the app.
    • The URL field is not LinkedIn-only. Any http: or https: URL passes; a blank one is allowed.
    • Search covers company and role only; notes, resume and salary are not searched. Sorting is by date applied only.
    • Ids come from crypto.randomUUID(), with a job_<timestamp>_<random> fallback, so tests should find cards by text, not by id.