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.
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:
Area
What to test
Why it can break
Storage
Add, edit, move and delete survive a reload; a fresh browser profile starts empty
Every change awaits an IndexedDB write before React state updates
Validation
Required company, role and date; URL must be http or https; blank URL allowed
Rules live in validate() and isValidUrl() only
Dates
"Today", "1 day", "Future", "No date" across time zones and daylight-saving changes
UTC parsing and local date parts are mixed (bugs 1 and 2)
Drag and drop
A drop on another column saves; a drop on the same column saves nothing
dnd-kit starts a drag only after 8 px of pointer movement
Backup
Export shape; import of good, partial and hostile JSON; the replace confirmation
Imported JSON is untrusted input that goes straight into storage (bug 3)
Search and sort
Company and role only, case-insensitive; newest or oldest by date applied
Notes, 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
In the real app React unmounts the whole page. The import was already written to IndexedDB, so every reload crashes again until the site data is cleared.
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.
File
What 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.js
The IndexedDB layer through idb: getAllJobs, saveJob (put), deleteJob, replaceJobs (clear and put in one transaction).
src/constants.js
STATUS_COLUMNS with each column's Tailwind colours, STATUS_BY_ID, todayInputValue() and blankJobForm().
src/main.jsx, src/index.css
Mounts <App /> in React.StrictMode; Tailwind layers and base resets.
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]);
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 2026
Form pre-fills
Card for a job applied today
Card for 2026-10-02
UTC, 10:00
2026-10-09
Today
7 days
America/New_York, 10:00
2026-10-09
1 day
8 days
America/New_York, 21:00
2026-10-10 (tomorrow)
"Today" for that date; 2026-10-09 shows "1 day"
8 days
Asia/Kolkata, 02:00
2026-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");
exportconst todayInputValue = () => {
const d = newDate();
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 DSTconst daysSince = (value) => {
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value || "");
if (!match) return"No date";
const today = newDate();
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 printsnpm 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 reach
Locator, from the source code
Add a job
getByRole('button', { name: 'Add Job' }), or { name: 'Add to Applied' } for a column
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.
DDrills for the project
Code drills use Project_Job_TRACKERAI: run it with npm run dev, or copy a helper into Node. Playwright drills target the live demo on the Page tab; turn on Show locator badges to see the test ids.
Boundary-test daysSince
With the clock at 9 Oct 2026, 10:00 UTC, what does daysSince return for today, yesterday, tomorrow, an empty string and ten days ago?
Expected result
Today, 1 day, Future, No date, 10 days. In UTC the date maths is right; the bugs need another time zone.
Probe the URL rule
Save a job with the URL linkedin.com/jobs/1, then ftp://example.com, then an empty URL.
Expected result
Enter a valid URL twice (no protocol, then a protocol that is not http or https), then a successful save: a blank URL is allowed.
Import with an unknown statusPlaywright
In the demo, import [{"company": "Acme", "role": "QA Engineer", "status": "ghosted"}] (the default text), confirm, and assert the board.
The question reads Import 1 jobs and replace the current board?. After Replace board: Backup imported, one card in Wishlist, 1 job in total, dated 2026-10-09.
Import files that should fail
Import [{"company": "Acme"}], then {"items": []}, then the word hello.
Expected result
No valid jobs found (the row has no role), then Invalid backup file (no array), then the browser's own parser error rather than an app message. In Chrome: Unexpected token 'h', "hello" is not valid JSON. Nothing is replaced in any of the three.
Drop a card on its own columnPlaywright
In the demo, drag the Acme Payments card (jobt-card-job-1, in Applied) and drop it back on jobt-col-applied with dragTo. Then drag it to jobt-col-interview.
Expected result
The first drop saves nothing: the log says dropped on its own column: nothing saved, just as handleDragEnd returns early in the app. The second moves the card, jobt-count-interview becomes 2, and the stored record shows "status": "interview" with a new updatedAt.
Catch bug 1 in the demoPlaywright
In the demo, set jobt-tz to UTC-4, add a job with the default date, and assert its day label. Then tick jobt-fix.
Hint
The seven sample cards are job-1 to job-7, so the new card is jobt-days-job-8.
Expected result
The form pre-fills 2026-10-09 and the new card says 1 day. With the fix it says Today. Against the real app, use timezoneId: 'America/New_York' and page.clock.setFixedTime(new Date('2026-10-09T14:00:00Z')).
Crash the board with one importPlaywright
Import [{"company": "Acme", "role": "QA Engineer", "status": "constructor"}], confirm, and assert what happens. Then clear the site data.
Expected result
jobt-crash appears with TypeError: grouped[status].push is not a function and the board is hidden. Clear site data brings back an empty board (0 jobs). With jobt-fix ticked, the same import lands in Wishlist instead.
Reproduce the daylight-saving bug
Run daysSince("2027-03-20") with TZ=Europe/Berlin and the clock at 2027-04-05 08:00 UTC (10:00 in Berlin).
Hint
Copy daysSince from App.jsx lines 41-56 into a script and override Date so new Date() returns that instant.
Expected result
15 days, but the calendar says 16. Germany moved its clocks forward on 28 March, so that day had 23 hours and Math.floor drops the difference.
SSolutions: the demo spec and the source
The Playwright spec passes against the demo as written. The other tabs are the exact code from the course repo: the storage layer, the helpers where bugs 1 to 3 live, and the event handlers behind every write.
tests/job-tracker-ai-demo.spec.ts
import { test, expect } from'@playwright/test';
const URL = 'https://app.thetestingacademy.com/ai/blueprint/learn/job-tracker-ai.html';
test('a new job lands in its column and the stored record matches', async ({ page }) => {
await page.goto(URL);
await page.getByTestId('jobt-add-applied').click();
await page.getByTestId('jobt-company').fill('Acme Robotics');
await page.getByTestId('jobt-role').fill('QA Engineer');
await page.getByTestId('jobt-save').click();
awaitexpect(page.getByTestId('jobt-toast')).toHaveText('Job added');
awaitexpect(page.getByTestId('jobt-count-applied')).toHaveText('3');
awaitexpect(page.getByTestId('jobt-total')).toHaveText('8 jobs');
awaitexpect(page.getByTestId('jobt-record')).toContainText('"status": "applied"');
});
test('validation blocks a save with missing fields and a bad URL', async ({ page }) => {
await page.goto(URL);
await page.getByTestId('jobt-add').click();
await page.getByTestId('jobt-url').fill('linkedin.com/jobs/1');
await page.getByTestId('jobt-save').click();
awaitexpect(page.getByTestId('jobt-err-company')).toHaveText('Company is required');
awaitexpect(page.getByTestId('jobt-err-role')).toHaveText('Role is required');
awaitexpect(page.getByTestId('jobt-err-url')).toHaveText('Enter a valid URL');
awaitexpect(page.getByTestId('jobt-total')).toHaveText('7 jobs');
});
test('in UTC-4 a job added today says 1 day until the fix is applied', async ({ page }) => {
await page.goto(URL);
await page.getByTestId('jobt-tz').selectOption('-240');
await page.getByTestId('jobt-add').click();
awaitexpect(page.getByTestId('jobt-date')).toHaveValue('2026-10-09');
await page.getByTestId('jobt-company').fill('Acme Robotics');
await page.getByTestId('jobt-role').fill('QA Engineer');
await page.getByTestId('jobt-save').click();
awaitexpect(page.getByTestId('jobt-days-job-8')).toHaveText('1 day');
await page.getByTestId('jobt-fix').check();
awaitexpect(page.getByTestId('jobt-days-job-8')).toHaveText('Today');
});