A simple full-stack task board (Board → Columns → Tasks), built for the TaskFlow take-home assignment.
- Frontend: React 18 + JavaScript + Vite
- Backend: Node.js + Express
- Database: SQLite via
better-sqlite3(synchronous, real SQL — no ORM query builder hiding the queries) - Tests: Vitest + Supertest
- Board with To Do / In Progress / Done columns
- Create, edit, and delete tasks
- Move a task between columns via a dropdown (reliable over drag-and-drop, see Decisions below)
- Low / Medium / High priority, set on create/edit
- Filter visible tasks by priority
- Task count shown in each column header
- Backend validation: empty/whitespace-only titles are rejected with a 400, invalid priorities and invalid columns are rejected too
- Frontend shows a real error banner (not a blank screen) if a request fails, and a "try again" state if the board itself can't load
- Persistent SQLite database — reloading the page shows the same data
- Seed data so the board isn't empty on first run
- Two non-trivial SQL queries (see Database below)
- Backend tests covering validation, moving a task, and a direct database-layer query
- Node.js 18 or newer (Node 20/22 recommended)
- npm
# from the project root
npm install
npm run install:all
npm run dev- Frontend: http://localhost:5173
- Backend: http://localhost:5000 (http://localhost:5000/api/health should return
{"ok":true})
The frontend's dev server proxies /api/* requests to the backend (see client/vite.config.js),
so there's no CORS setup to worry about and no hardcoded port baked into the UI.
The SQLite database file is created automatically at server/data/taskflow.db the first time the
backend starts, and seeded with one board, three columns, and a few sample tasks if it's empty.
npm run dev --prefix server # backend only, http://localhost:5000
npm run dev --prefix client # frontend only, http://localhost:5173npm testThis runs the backend test suite (Vitest + Supertest) against a throwaway SQLite file
(server/tests/test-taskflow.db), which is created fresh and deleted after each run. It covers:
- Creating a task with an empty/whitespace title fails with a 400.
- Moving a task to another column updates its
column_id. - The
getTaskCountPerColumndatabase query returns the expected shape against known seed data.
Schema: server/schema.sql. Applied automatically on backend startup
(CREATE TABLE IF NOT EXISTS, so it's safe to re-run).
CREATE TABLE boards (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL
);
CREATE TABLE columns (
id INTEGER PRIMARY KEY AUTOINCREMENT,
board_id INTEGER NOT NULL,
name TEXT NOT NULL,
position INTEGER NOT NULL,
FOREIGN KEY (board_id) REFERENCES boards(id) ON DELETE CASCADE
);
CREATE TABLE tasks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
column_id INTEGER NOT NULL,
title TEXT NOT NULL,
description TEXT,
priority TEXT NOT NULL DEFAULT 'Medium' CHECK (priority IN ('Low', 'Medium', 'High')),
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
FOREIGN KEY (column_id) REFERENCES columns(id) ON DELETE CASCADE
);Foreign keys are enforced (PRAGMA foreign_keys = ON), and there are indexes on
columns.board_id, tasks.column_id, and tasks.priority since those are the columns queried
and joined on most often.
Seed data lives in server/src/db.js (seedDatabase) and only runs when the
boards table is empty, so restarting the server never duplicates data.
The two required non-trivial queries (both in server/src/db.js, both real SQL — not
"fetch everything and filter in JS"):
-- Task count per column on a board
SELECT c.id, c.name, COUNT(t.id) AS task_count
FROM columns c
LEFT JOIN tasks t ON t.column_id = c.id
WHERE c.board_id = ?
GROUP BY c.id, c.name
ORDER BY c.position;
-- Tasks with a given priority, newest first
SELECT t.id, t.title, t.description, t.priority, t.created_at, c.name AS column_name
FROM tasks t
JOIN columns c ON c.id = t.column_id
WHERE t.priority = ?
ORDER BY datetime(t.created_at) DESC, t.id DESC;The second one is also exposed as an API endpoint (GET /api/tasks?priority=High) independent of
the main board view, so it's genuinely queried from the database rather than derived from data
already fetched for the board.
| Method | Route | Description |
|---|---|---|
| GET | /api/health |
Health check |
| GET | /api/board |
The board with its columns and tasks |
| GET | /api/tasks?priority=High |
Tasks of a given priority, newest first |
| GET | /api/boards/:boardId/task-counts |
Task count per column |
| POST | /api/tasks |
Create a task |
| PUT | /api/tasks/:id |
Edit a task's title/description/priority |
| PATCH | /api/tasks/:id/move |
Move a task to another column |
| DELETE | /api/tasks/:id |
Delete a task |
All mutating routes validate their input server-side (not just in the React form) and return a
JSON { "error": "..." } body with an appropriate 4xx status on bad input.
Simplest path — deploy the backend as a web service on Render/Railway/Fly.io:
- Point the service at the
server/directory, build commandnpm install, start commandnpm start. - Note the deployed backend URL, e.g.
https://taskflow-api.onrender.com. - Deploy
client/as a static site (Vercel, Netlify, or the same host), setting the build command tonpm run build, output directorydist, and an environment variableVITE_API_URL=https://taskflow-api.onrender.com/api.
SQLite is a file on disk, so on hosts with an ephemeral filesystem (like Render's free tier) the data resets on redeploy/restart — worth mentioning to reviewers, and fine for a take-home.
- One default board, since multi-board/multi-team support and auth are explicitly out of scope.
- Moving a task uses a dropdown rather than drag-and-drop, per the assignment's own guidance that a working dropdown beats a broken drag-and-drop within a time-boxed assignment.
- The frontend calls a relative
/apipath, proxied to the backend by Vite in dev (client/vite.config.js). This avoids hardcodinglocalhost:5000into the UI and sidesteps CORS entirely in local dev, while still allowing a split deployment viaVITE_API_URL. - Timestamps are stored as ISO-8601 (
YYYY-MM-DDTHH:MM:SS.sssZ) rather than SQLite's defaultCURRENT_TIMESTAMPformat, sonew Date(task.created_at)parses reliably in every browser (the default space-separated format isn't reliably parsed byDateoutside Chrome). - SQLite via
better-sqlite3because the assignment explicitly allows it and its synchronous API keeps the query code simple and easy to read for a small project like this.
Drag-and-drop, title search, pagination for larger boards, optimistic UI updates on move/delete instead of a full board reload, and a persistent (non-ephemeral) deployment.
About 2–3 hours.
Storing created_at as SQLite's default CURRENT_TIMESTAMP looks harmless until you actually
parse it with new Date(...) in a browser other than Chrome — the lack of a T/Z in that format
isn't reliably parsed per the ECMAScript spec, so it's an easy bug to ship without ever noticing on
one machine.