A small, fast task management app built with Nuxt 3 (Composition API + TypeScript), Pinia, and Tailwind CSS. Nuxt serves both the frontend and the REST API, so there's nothing else to stand up locally.
This project was developed using Node.js v25.6.1 and npm 11.9.0.
Older supported versions (such as Node 18 and 20) are expected to work correctly, even though development was done on newer versions.
Check your version:
node -v
npm -vnpm installThis also runs nuxt prepare automatically (via postinstall), which generates the .nuxt type helpers.
npm run devThe app runs at http://localhost:3000. Task data lives in data/tasks.json and is seeded with 3 example tasks the first time you run it. Feel free to edit or delete that file to reset the demo data; an empty or missing file is treated as zero tasks and won't crash the app.
npm run build
node .output/server/index.mjsOr, to preview via Nuxt's own preview command:
npm run previewnpm run test # single run
npm run test:watch # watch mode
npm run test:coverage # with coverage reportTest coverage is split across 9 files:
tests/unit/validateTask.spec.ts: server-side validation (create + partial/update modes)tests/unit/db.spec.ts: the task repository, both the file backend and the Upstash Redis backend (CRUD, missing-id handling, concurrent-write safety, Redis seeding)tests/unit/tasksStore.spec.ts: the Pinia store (fetch/create/update/delete, filtering, search, counts, error handling)tests/unit/useTaskForm.spec.tsanduseDebouncedValue.spec.ts: composablestests/components/*.spec.ts:TaskForm,TaskCard,StatusBadge,ConfirmDialogcomponent behavior
npm run lint
npm run typecheck├── assets/css/main.css Design tokens & Tailwind layer utilities
├── components/ Reusable Vue components (all script setup + TS)
├── composables/ useDebouncedValue, useTaskForm
├── data/tasks.json The "database": a flat JSON file
├── pages/ index.vue (list) and tasks/[id].vue (detail/edit)
├── server/api/tasks/ REST endpoints (GET/POST/PUT/DELETE), Nitro
├── server/utils/ db.ts (file/Redis repository), validateTask.ts
├── stores/tasks.ts Pinia store, single source of truth on the client
├── tests/ Vitest unit + component tests
├── types/task.ts Shared Task/TaskInput/TaskStatus types
└── .github/workflows/lighthouse.yml CI performance gate
All requests go through Nitro's server routes (server/api/tasks/*.ts), which read and write task data through server/utils/db.ts. Locally that's the flat data/tasks.json file, same as before — clone the repo, npm install && npm run dev, and it just works.
In production on Vercel it switches to Upstash for Redis instead, and that's not optional polish — a JSON file genuinely doesn't work there. Nuxt runs on Vercel as serverless functions rather than one long-lived server, and those functions can't reliably write back to a file sitting in their own deployment bundle. db.ts picks whichever backend applies automatically based on whether UPSTASH_REDIS_REST_URL is present in the environment, so nothing else in the app needs to know which one is active.
Worth a note if you've used Vercel before: this used to be "Vercel KV", a native Vercel product. Vercel retired that in favor of Marketplace integrations, and Upstash's Redis integration is the direct replacement.
The first read against an empty store seeds it from data/tasks.json, so a fresh deploy still shows the same 3 example tasks instead of a blank list. Writes still go through an internal queue either way, though on Vercel each request can land on a different function instance, so that queue mainly protects against races within a single instance rather than across the whole deployment. Fine for a demo; not something to lean on for real concurrent traffic.
- Import the repo into Vercel as a new project. It detects Nuxt on its own, no
vercel.jsonrequired. - In the project dashboard: Storage tab → Marketplace Database Providers → Upstash → pick Redis.
- Connect that store to the project. Vercel injects
UPSTASH_REDIS_REST_URL,UPSTASH_REDIS_REST_TOKEN, and a couple of related vars automatically, nothing to copy in by hand. - Redeploy — env vars only take effect on deployments created after the store was connected.
Local dev doesn't need any of this. Without those env vars set, the app just falls back to data/tasks.json, same as always.
| Method | Path | Body | Notes |
|---|---|---|---|
| GET | /api/tasks |
— | Returns all tasks, newest first |
| GET | /api/tasks/:id |
— | 404 if not found |
| POST | /api/tasks |
{ title, description, status, dueDate } |
422 on validation failure; due date must be in the future |
| PUT | /api/tasks/:id |
Any subset of the fields above | Partial update; due date isn't required to be in the future (see below) |
| DELETE | /api/tasks/:id |
— | 204 on success, 404 if not found |
- Typography: the system font stack (
ui-sans-serif,-apple-system,Segoe UI, etc.) is used instead of a webfont. Zero extra font network requests, no layout shift while a font loads, which helps Core Web Vitals (no font-swap CLS, faster LCP). - Signature element: the small 3-segment "stage tracker" bar on each task card and the detail page encodes the actual workflow position (Pending → In Progress → Done) instead of just repeating the status pill. It's a second, at-a-glance way to scan task state across a grid.
- All dialogs are custom (
BaseModal.vue,ConfirmDialog.vue). Nowindow.confirm/alert; built with<Teleport>, focus-safe close onEscapeand backdrop click, androle="dialog"/aria-modalfor accessibility. - Responsive: task grid is 1 column on mobile, 2 on tablet, 3 on desktop (
sm:grid-cols-2 xl:grid-cols-3). Filters stack vertically on narrow screens and go inline on wider ones. The modal and detail page cap width and scroll internally so nothing overflows on small viewports.
.github/workflows/lighthouse.yml runs on every push to main:
- Installs dependencies and runs the full Vitest suite.
- Builds the app for production (
npm run build). - Boots the built server (
node .output/server/index.mjs). - Runs
@lhci/cliagainstlighthouserc.json, which assertscategories:performancemust score ≥ 0.85 (85%) on both the task list and a task detail page, across 3 runs (LHCI takes the median automatically). - If the score drops below 85%,
lhci autorunexits non-zero and the workflow fails the build. That's LHCI's built-in assertion behavior; no extra scripting needed.
To run the same check locally:
npm run build
node .output/server/index.mjs &
npx @lhci/cli@0.14.x autorun --config=./lighthouserc.jsonAuto-updated by CI on every push to main. Last run: 2026-07-23 08:09 UTC (commit daeb90b), averaged across 6 Lighthouse runs. Report links are temporary and expire after about 7 days — the numbers below are the permanent record.
| Page | Performance | Accessibility | Best Practices | SEO | Report |
|---|---|---|---|---|---|
/ |
100 | 100 | 100 | 100 | View → |
/tasks/:id |
100 | 100 | 100 | 100 | View → |
The two full HTML report links each run generates (storage.googleapis.com/...report.html) are temporary and expire after about a week — that's a temporary-public-storage limitation of Lighthouse CI itself, not something this repo can control. The table above is the permanent record; click through to a report link while it's fresh if you want the full trace/waterfall view, or check the latest workflow run directly for the current pair of links.
- Single shared task list for every visitor (a JSON file locally, one Redis store in production); no auth, no per-user data.
- No real-time updates between open tabs/clients. Refresh or re-navigate to see another session's changes.
- No pagination, which is fine at demo scale.
A few things worth tackling if this grows past a demo, roughly in the order I'd do them:
Swap the JSON file for a real database. It's the main thing blocking a proper deployment. Upstash for Redis (via Vercel's Marketplace) is the quickest fit since the app already targets Vercel and Vercel's own native KV product has since been retired in favor of it, but Supabase (or plain Postgres) makes more sense the moment this needs relations between tables, proper querying, or anything beyond a flat task list.(Done ✅)- Auth and per-user data. Right now every visitor shares one task list. Even something simple (a login plus a
userIdon each task) would fix that. - Real-time sync across tabs. Two tabs open right now don't know about each other's changes until you refresh. Worth revisiting once there's a real database under it — polling or websockets both become a lot easier with actual persistence instead of a flat file.
- Pagination, once the task list is big enough that loading everything at once stops making sense.
- Rate limiting and tighter input validation on the API routes before this sits anywhere near real traffic.