diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 8b13789..7e5a75b 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1 +1,37 @@ +## Summary + + +## Motivation + + + +## Changes + + +- +- + +## How was this tested? + + +- [ ] `npm run lint` +- [ ] `npm run typecheck` +- [ ] `npm run build` +- [ ] Manual UI walkthrough (login → create → filter → edit → complete → delete → logout) +- [ ] API smoke tests with `curl` (see README "REST API reference") + +## Checklist + +- [ ] Code follows the layer boundaries described in spec §1.4.1 + (presentation never touches the filesystem directly). +- [ ] New / changed API routes return appropriate HTTP status codes + (200 / 201 / 400 / 401 / 404). +- [ ] Task routes still require the `prismtask_user` session cookie. +- [ ] No secrets, credentials, or personal data added to the repository. +- [ ] Commit messages follow Conventional Commits (`feat:` / `fix:` / + `refactor:` / `docs:` / `test:` / `chore:`). + +## Screenshots / recordings + + diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8b13789..ccc6e41 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1 +1,39 @@ +name: CI +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + build: + name: Lint · Typecheck · Build + runs-on: ubuntu-latest + + defaults: + run: + working-directory: frontend/prismtask + + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + cache-dependency-path: frontend/prismtask/package-lock.json + + - name: Install dependencies + run: npm ci + + - name: Lint + run: npm run lint + + - name: Typecheck + run: npm run typecheck + + - name: Build + run: npm run build diff --git a/GUIDE.md b/GUIDE.md new file mode 100644 index 0000000..c50f637 --- /dev/null +++ b/GUIDE.md @@ -0,0 +1,386 @@ +# PrismTask — How to run and how to push + +A complete walkthrough for running the app locally and pushing your changes +to GitHub. Works on macOS, Linux, and Windows (PowerShell or WSL). + +--- + +## Part 1 — Running the program + +### 1.1 Prerequisites + +You need these installed once per machine: + +| Tool | Version | Check with | +| ------- | ------- | ------------------- | +| Node.js | 18.17+ | `node --version` | +| npm | 9+ | `npm --version` | +| Git | any | `git --version` | + +If you're missing any, install from: +- Node + npm: (the LTS installer includes npm) +- Git: + +### 1.2 First-time setup + +Clone the repo and install dependencies: + +```bash +git clone https://github.com/Felix772/prism-todo-cpp.git +cd prism-todo-cpp/frontend/prismtask +npm install +``` + +`npm install` reads `package-lock.json` and pulls down exact dependency +versions. First run takes 30–60 seconds; subsequent runs are instant. + +### 1.3 Seed the demo data (only if `data/` is empty) + +The repo ships `data/users.json` pre-seeded with the demo account, and +`data/tasks.json` as an empty array. If for any reason those files are +missing, recreate them: + +```bash +mkdir -p data +echo '[]' > data/tasks.json +echo '[{"id":1,"email":"demo@prismtask.com","password":"password123"}]' > data/users.json +``` + +On Windows PowerShell the `echo` syntax is slightly different — use: + +```powershell +mkdir -Force data | Out-Null +Set-Content data/tasks.json '[]' +Set-Content data/users.json '[{"id":1,"email":"demo@prismtask.com","password":"password123"}]' +``` + +### 1.4 Start the dev server + +```bash +npm run dev +``` + +You'll see output like: + +``` +▲ Next.js 16.2.1 +- Local: http://localhost:3000 +✓ Ready in 812ms +``` + +Open in your browser. You should see the login +screen with the animated shard background. + +### 1.5 Log in + +Use the pre-seeded demo account: + +- **Email:** `demo@prismtask.com` +- **Password:** `password123` + +Press **Enter** or click **Login**. You'll be redirected to `/tasks`. + +### 1.6 Exercise the full workflow + +From the task dashboard you can: + +1. **Create a task** — type a title, pick High / Medium / Low, optionally + set a due date, click **Save Task**. +2. **Filter by priority** — click one of `All / High / Medium / Low` in + the filter row. The list refetches with `?priority=…` on the query + string. +3. **Edit a task** — click **Edit** on any row; change fields; click + **Save**. `id` and `createdAt` are preserved automatically. +4. **Toggle complete** — click the circle on the left of any row. +5. **Delete a task** — click the **✕** on the right. +6. **Log out** — click **Log out** at the top-right; the session cookie + is cleared and you're sent back to `/`. + +All changes persist to `data/tasks.json` immediately (atomic write — +tmp-file + rename). + +### 1.7 Quick API smoke test (optional) + +If you want to confirm the REST API works independently of the UI: + +```bash +# Log in and save the session cookie +curl -c cookies.txt -X POST http://localhost:3000/api/auth/login \ + -H 'Content-Type: application/json' \ + -d '{"email":"demo@prismtask.com","password":"password123"}' + +# Create a task +curl -b cookies.txt -X POST http://localhost:3000/api/tasks \ + -H 'Content-Type: application/json' \ + -d '{"title":"Write report","priority":"High"}' + +# List High-priority tasks +curl -b cookies.txt 'http://localhost:3000/api/tasks?priority=High' +``` + +### 1.8 Production build (to test the optimised bundle) + +```bash +npm run build # produces .next/ +npm start # serves the built bundle at http://localhost:3000 +``` + +### 1.9 Useful commands + +| Command | What it does | +| ------------------- | --------------------------------------------- | +| `npm run dev` | Start dev server with hot reload. | +| `npm run build` | Production build + strict TypeScript pass. | +| `npm start` | Serve the production build. | +| `npm run lint` | ESLint (must pass — CI gate). | +| `npm run typecheck` | `tsc --noEmit` — strict type check. | + +### 1.10 Troubleshooting + +- **"Port 3000 is in use"** — another process has the port. Either stop + it, or run `npm run dev -- -p 3001` to use port 3001 instead. +- **Login returns 401** — check `data/users.json` exists and contains the + demo account (see §1.3 above). +- **`GET /api/tasks` returns 401** — that's expected when you're not + logged in. Log in first (the UI does this automatically; `curl` + callers need to pass the `prismtask_user` cookie). +- **"Failed to fetch Google Fonts" during `npm run build`** — you're on + an offline / restricted network. The current code doesn't use Google + Fonts, so if you see this you're probably on an older checkout; pull + the latest `main`. +- **Animations are too busy** — enable "Reduce motion" in your OS + settings. PrismTask honours `prefers-reduced-motion` and freezes the + shard animations automatically. + +--- + +## Part 2 — Pushing your changes to GitHub + +### 2.1 One-time setup + +Configure Git with your name and GitHub email (only required once per +machine): + +```bash +git config --global user.name "Your Name" +git config --global user.email "you@example.com" +``` + +If you haven't authenticated with GitHub before, the easiest path is the +GitHub CLI: + +```bash +# macOS: brew install gh +# Ubuntu: sudo apt install gh +# Windows: winget install --id GitHub.cli + +gh auth login # pick HTTPS, then the browser flow +``` + +Alternatively set up an SSH key — see +. + +### 2.2 The everyday workflow + +The team uses a **trunk-based** workflow with short-lived feature branches +(spec §2.1). Every change lands on `main` via a reviewed pull request. + +**1. Make sure you're on `main` and up to date:** + +```bash +git checkout main +git pull origin main +``` + +**2. Create a feature branch** with a descriptive name: + +```bash +git checkout -b feature/ +# examples: +# feature/task-edit +# feature/priority-filter +# fix/session-cookie-expiry +``` + +**3. Make your changes**, test locally, then stage and commit using +**Conventional Commits** (spec §2.1.2): + +```bash +git add # stage specific files (preferred) +# or: +git add -A # stage everything + +git commit -m "feat: add priority filter UI to task dashboard" +``` + +Commit-type prefixes: + +| Prefix | Use for | +| ------------ | ---------------------------------------------- | +| `feat:` | New user-visible feature | +| `fix:` | Bug fix | +| `refactor:` | Code change that doesn't add or fix behaviour | +| `docs:` | README, comments, this guide | +| `test:` | Adding / updating tests | +| `chore:` | Tooling, CI, dependencies | + +One logical change per commit — don't mix a refactor and a feature in one +commit. Smaller commits are easier to review and easier to revert. + +**4. Push the branch to GitHub:** + +```bash +git push -u origin feature/ +``` + +The `-u` flag sets the upstream so future `git push` / `git pull` from +this branch "just work" with no extra arguments. + +**5. Open a pull request:** + +Either in the browser at +, or from the CLI: + +```bash +gh pr create --base main --fill +``` + +The **PR template** (`.github/PULL_REQUEST_TEMPLATE.md`) will auto-fill. +Answer every section — especially the "How was this tested?" checklist. + +**6. Wait for CI and review:** + +CI runs `npm run lint`, `npm run typecheck`, and `npm run build` on every +push. A green check appears next to your PR when they all pass. If CI +fails, open the **Details** link to see which step broke. + +Per spec §2.1.1, `main` is protected and requires **at least one +reviewer approval** before merge. + +**7. Address review comments:** + +Make the changes locally, then: + +```bash +git add +git commit -m "refactor: rename loadTasks to refreshTasks per review" +git push +``` + +The new commits attach to the same PR automatically. + +**8. Merge:** + +When the PR is approved and CI is green, click **Squash and merge** on +GitHub (spec §2.1.1 — feature branches are squash-merged into `main`). +Squashing collapses your commit history into a single clean commit on +`main`. + +**9. Clean up:** + +```bash +git checkout main +git pull origin main +git branch -d feature/ # delete local branch +git push origin --delete feature/ # delete remote branch +``` + +### 2.3 Hotfix workflow + +For urgent fixes that must skip the normal queue (spec §2.1.1): + +```bash +git checkout main +git pull origin main +git checkout -b hotfix/ +# ... fix, commit, push ... +gh pr create --base main --title "hotfix: " +``` + +Hotfix PRs need only **one approver** and are fast-tracked. + +### 2.4 Common situations + +**"Your branch is behind `main`"** — someone else merged while you were +working. Rebase your branch on top of the new `main`: + +```bash +git checkout main +git pull origin main +git checkout feature/ +git rebase main +# resolve any conflicts, then: +git push --force-with-lease +``` + +Always use `--force-with-lease` instead of `--force` — it refuses to push +if the remote has moved unexpectedly, which protects teammates' work. + +**"I committed to `main` by accident"** — move the commit to a branch: + +```bash +git branch feature/accidental-commit # mark current state as a branch +git reset --hard origin/main # rewind local main +git checkout feature/accidental-commit # switch to the branch +git push -u origin feature/accidental-commit +``` + +**"I need to undo my last commit before pushing"**: + +```bash +git reset --soft HEAD~1 # keep changes staged +# or: +git reset --hard HEAD~1 # throw changes away — careful! +``` + +**"I committed the wrong file"** — remove it from the last commit (only +safe if you haven't pushed yet): + +```bash +git restore --staged +git commit --amend --no-edit +``` + +### 2.5 What NOT to commit + +- `node_modules/` — regenerated by `npm install`. +- `.next/` — regenerated by `npm run build`. +- `tsconfig.tsbuildinfo` — TypeScript incremental-build cache. +- `next-env.d.ts` — auto-generated by Next.js on build. +- `.env.local` or any real `.env` file — these hold secrets. +- Real user data in `data/tasks.json` or `data/users.json` — the + `.gitignore` protects against this, but double-check with + `git status` before committing. + +The `.gitignore` already excludes all of the above. If `git status` shows +any of them as untracked, something's wrong — stop and investigate before +committing. + +### 2.6 Quick reference card + +```bash +# start a feature +git checkout main && git pull +git checkout -b feature/my-change + +# commit +git add -A +git commit -m "feat: clear description of the change" + +# push and open PR +git push -u origin feature/my-change +gh pr create --base main --fill + +# after merge, clean up +git checkout main && git pull +git branch -d feature/my-change +``` + +--- + +## Questions? + +- Spec details — see `PrismTask.docx` (submitted separately). +- Architecture deep-dive — README §"Architecture at a glance" and spec §1.4. +- Known issues / tech debt — README §"Known shortcuts / tech debt" and + spec §4.1 risk register. diff --git a/README.md b/README.md index 77e0c29..99ce754 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,225 @@ -# prism-todo-cpp -Team Project for CEN3101 at UF, Spring 2026 semester: To-do list app with priority levels for each event identified by color. +# PrismTask -Note: The repository name was kept from an earlier project setup, but the current implementation for this submission is the Next.js/TypeScript PrismTask web application located in the repository. +> A task manager you actually want to open every day. + +PrismTask is a full-stack [Next.js](https://nextjs.org) + TypeScript web application +that pairs a clean CRUD task workflow with a glass-morphism login screen and an +animated, parallax-driven shard background. Built by **Prism Partners** for +CEN3101 at the University of Florida, Spring 2026. + +Team: Yibo Mao · Matthew Sama · Jiantong Yao · Zaichen Hao + +> **Note on the repository name.** This repository is named `prism-todo-cpp` +> for historical reasons (an earlier project scaffold). The current +> submission is the Next.js/TypeScript application under +> [`frontend/prismtask`](./frontend/prismtask). + +--- + +## Quick start + +```bash +git clone https://github.com/Felix772/prism-todo-cpp.git +cd prism-todo-cpp/frontend/prismtask +npm install +npm run dev +``` + +Then open . + +**Demo credentials** (pre-seeded): + +| Field | Value | +| -------- | ----------------------- | +| Email | `demo@prismtask.com` | +| Password | `password123` | + +Session lifetime is 8 hours (HTTP-only cookie). No API keys or external +services are required. + +--- + +## Repository layout + +``` +prism-todo-cpp/ +├── .github/ # CI workflow, PR + issue templates +│ ├── ISSUE_TEMPLATE/ +│ ├── PULL_REQUEST_TEMPLATE.md +│ └── workflows/ci.yml +├── docs/ # Supplementary documentation +├── frontend/prismtask/ # The Next.js application (all active code) +│ ├── app/ +│ │ ├── api/ +│ │ │ ├── auth/login/route.ts # POST / DELETE session cookie +│ │ │ ├── tasks/route.ts # GET list, POST create +│ │ │ └── tasks/[id]/route.ts # PATCH / DELETE one task +│ │ ├── tasks/page.tsx # Task dashboard +│ │ ├── page.tsx # Login page +│ │ ├── layout.tsx +│ │ └── globals.css +│ ├── lib/ +│ │ ├── auth.ts # Session-cookie helper +│ │ └── taskStore.ts # Data access (atomic writes) +│ ├── data/ # Local JSON persistence (git-ignored) +│ │ ├── tasks.json +│ │ └── users.json +│ ├── .env.example # Env template for future features +│ ├── package.json +│ └── tsconfig.json +├── LICENSE +└── README.md +``` + +The layered architecture (Presentation → API → Data Access → Persistence) is +described in **Section 1.4** of the submission report (`PrismTask.docx`). + +--- + +## First-run data setup + +`data/tasks.json` and `data/users.json` are created automatically on the +first API call. If you start from an empty working tree, you can seed the +demo account manually: + +```bash +mkdir -p data +echo '[]' > data/tasks.json +echo '[{"id":1,"email":"demo@prismtask.com","password":"password123"}]' > data/users.json +``` + +These files live inside `frontend/prismtask/data/` and are git-ignored (spec +§4.1 R-04) so real user data never lands in the repository. + +--- + +## Available scripts + +From inside `frontend/prismtask/`: + +| Command | Purpose | +| -------------------- | ------------------------------------------------- | +| `npm run dev` | Start dev server at . | +| `npm run build` | Production build (runs the T-01 TypeScript pass). | +| `npm start` | Serve the production build. | +| `npm run lint` | ESLint on the whole project. | +| `npm run typecheck` | `tsc --noEmit` — strict type check, no output. | + +--- + +## REST API reference + +All task routes require the `prismtask_user` session cookie set by +`POST /api/auth/login`. Routes called without the cookie return **401**. + +| Method | Path | Purpose | Success | +| ------ | -------------------------- | ----------------------------------------- | ---------- | +| POST | `/api/auth/login` | Log in; sets session cookie. | 200 | +| DELETE | `/api/auth/login` | Log out; clears session cookie. | 200 | +| GET | `/api/tasks` | List tasks. Optional `?priority=High`. | 200 | +| POST | `/api/tasks` | Create a task. | 201 | +| PATCH | `/api/tasks/{id}` | Update any field on a task. | 200 | +| DELETE | `/api/tasks/{id}` | Delete a task. | 200 | + +Error responses: + +- **400** — missing / malformed body, invalid `priority` value. +- **401** — missing or invalid session cookie. +- **404** — task id does not exist (also returned for non-numeric ids). + +### Task object + +```ts +interface Task { + id: number; + title: string; + description: string; + priority: 'High' | 'Medium' | 'Low'; + completed: boolean; + dueDate: string; // YYYY-MM-DD or ISO-8601, empty string if unset + createdAt: string; // ISO-8601, server-generated +} +``` + +`id` and `createdAt` are server-owned and cannot be overwritten through +`PATCH`. + +### Example: log in, create, filter, complete, delete + +```bash +# Log in — saves the session cookie into ./cookies.txt +curl -i -c cookies.txt -X POST http://localhost:3000/api/auth/login \ + -H 'Content-Type: application/json' \ + -d '{"email":"demo@prismtask.com","password":"password123"}' + +# Create a task (authenticated) +curl -b cookies.txt -X POST http://localhost:3000/api/tasks \ + -H 'Content-Type: application/json' \ + -d '{"title":"Write report","priority":"High","dueDate":"2026-04-20"}' + +# List only High-priority tasks +curl -b cookies.txt 'http://localhost:3000/api/tasks?priority=High' + +# Toggle completion on task 1 +curl -b cookies.txt -X PATCH http://localhost:3000/api/tasks/1 \ + -H 'Content-Type: application/json' \ + -d '{"completed":true}' + +# Delete task 1 +curl -b cookies.txt -X DELETE http://localhost:3000/api/tasks/1 + +# Log out +curl -b cookies.txt -X DELETE http://localhost:3000/api/auth/login +``` + +--- + +## Architecture at a glance + +| Layer | Implementation | +| ----------------- | ------------------------------------------------------------------ | +| Presentation | React 19 + Next.js 16 App Router, Tailwind CSS utility classes | +| Application / API | Next.js Route Handlers under `app/api/…` | +| Data Access | `lib/taskStore.ts` — `readTasks()` / `writeTasks()` / `nextId()` | +| Persistence | Local JSON files (`data/tasks.json`, `data/users.json`) | +| Auth | HTTP-only, `SameSite=Lax`, 8-hour `prismtask_user` session cookie | + +Writes to `tasks.json` are atomic — the store writes to `tasks.json.tmp` +and renames into place, so a crash mid-write cannot produce a half-written +JSON file (spec §4.2.4 Reliability). + +--- + +## Known shortcuts / tech debt + +These are accepted trade-offs for the classroom demo, tracked in §4.1 of +the submission report: + +- **Plain-text passwords** in `users.json` (R-01). Must be replaced with + bcrypt before any non-classroom deployment. +- **JSON files, not a database** (R-05). The `taskStore` interface is the + single swap point for Prisma + PostgreSQL. +- **No write-lock** on `tasks.json` (R-03). Single-user demo never observes + concurrent writes. +- **No CSRF token** on state-changing routes (R-02). Cookie is `HttpOnly` + + `SameSite=Lax`, which mitigates but does not eliminate CSRF. + +See §4.1 of `PrismTask.docx` for the full risk register. + +--- + +## Tooling + +- **Language** — TypeScript 5.x (strict mode) +- **Framework** — Next.js 16 (App Router) +- **UI** — React 19, Tailwind CSS 4 +- **Lint** — ESLint 9 (`eslint-config-next`) +- **Runtime** — Node.js 18.17+ / npm 9+ + +Verified on macOS 14, Ubuntu 22.04, and Windows 11. + +--- + +## License + +See [LICENSE](./LICENSE). diff --git a/frontend/prismtask/.env.example b/frontend/prismtask/.env.example new file mode 100644 index 0000000..adec30b --- /dev/null +++ b/frontend/prismtask/.env.example @@ -0,0 +1,23 @@ +# PrismTask environment variables +# +# The current release persists to local JSON files and does NOT need any of +# the variables below — `npm run dev` works with no .env at all. This file +# documents the settings that will become relevant when we swap the taskStore +# for a hosted database (see spec §4.1 R-05 and the Next-Steps list). +# +# Copy this file to `.env.local` and fill in values as needed. The real +# `.env.local` is git-ignored (see frontend/prismtask/.gitignore). + +# --------------------------------------------------------------------------- +# Future: PostgreSQL connection string used by Prisma when we migrate the +# taskStore module behind a hosted database. Example format: +# DATABASE_URL="postgresql://USER:PASSWORD@HOST:5432/prismtask?schema=public" +# --------------------------------------------------------------------------- +# DATABASE_URL= + +# --------------------------------------------------------------------------- +# Future: CSRF token signing secret. Required once we add CSRF protection to +# state-changing routes per spec §4.1 R-02. Generate with: +# node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" +# --------------------------------------------------------------------------- +# SESSION_SECRET= diff --git a/frontend/prismtask/.gitignore b/frontend/prismtask/.gitignore index 5ef6a52..a2e93a1 100644 --- a/frontend/prismtask/.gitignore +++ b/frontend/prismtask/.gitignore @@ -20,6 +20,10 @@ # production /build +# Local JSON persistence (spec §4.1 R-04 — user data is not committed) +/data/*.json +!/data/.gitkeep + # misc .DS_Store *.pem @@ -32,6 +36,7 @@ yarn-error.log* # env files (can opt-in for committing if needed) .env* +!.env.example # vercel .vercel diff --git a/frontend/prismtask/README.md b/frontend/prismtask/README.md index e215bc4..dec4c77 100644 --- a/frontend/prismtask/README.md +++ b/frontend/prismtask/README.md @@ -1,36 +1,21 @@ -This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). +# PrismTask — Next.js app -## Getting Started +This is the Next.js / TypeScript application that implements PrismTask. -First, run the development server: +All user-facing documentation — install instructions, API reference, +architecture, known issues — lives in the **[root `README.md`](../../README.md)**. -```bash -npm run dev -# or -yarn dev -# or -pnpm dev -# or -bun dev -``` - -Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. - -You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. - -This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. - -## Learn More +## Quick commands -To learn more about Next.js, take a look at the following resources: +From this directory: -- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. -- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. - -You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! - -## Deploy on Vercel - -The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. +```bash +npm install +npm run dev # http://localhost:3000 +npm run build # production build + TypeScript check +npm run lint # ESLint +npm run typecheck # tsc --noEmit +npm start # serve production build +``` -Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. +Demo login: `demo@prismtask.com` / `password123`. diff --git a/frontend/prismtask/app/api/[id]/route.ts b/frontend/prismtask/app/api/[id]/route.ts deleted file mode 100644 index cfa6b83..0000000 --- a/frontend/prismtask/app/api/[id]/route.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { NextRequest, NextResponse } from 'next/server'; -import { readTasks, writeTasks } from '@/lib/taskStore'; - -type Params = { params: Promise<{ id: string }> }; - -// PATCH /api/tasks/[id] — toggle completed, or update fields -export async function PATCH(req: NextRequest, { params }: Params) { - const { id } = await params; - const taskId = parseInt(id, 10); - - if (isNaN(taskId)) { - return NextResponse.json({ error: 'invalid id' }, { status: 400 }); - } - - const tasks = readTasks(); - const index = tasks.findIndex((t) => t.id === taskId); - - if (index === -1) { - return NextResponse.json({ error: 'task not found' }, { status: 404 }); - } - - const body = await req.json(); - - // Allow toggling completed or updating any field - tasks[index] = { ...tasks[index], ...body, id: taskId }; - writeTasks(tasks); - - return NextResponse.json(tasks[index]); -} - -// DELETE /api/tasks/[id] — remove a task -export async function DELETE(_req: NextRequest, { params }: Params) { - const { id } = await params; - const taskId = parseInt(id, 10); - - if (isNaN(taskId)) { - return NextResponse.json({ error: 'invalid id' }, { status: 400 }); - } - - const tasks = readTasks(); - const index = tasks.findIndex((t) => t.id === taskId); - - if (index === -1) { - return NextResponse.json({ error: 'task not found' }, { status: 404 }); - } - - const [removed] = tasks.splice(index, 1); - writeTasks(tasks); - - return NextResponse.json(removed); -} diff --git a/frontend/prismtask/app/api/auth/login/route.ts b/frontend/prismtask/app/api/auth/login/route.ts index a29d8b1..729ffe5 100644 --- a/frontend/prismtask/app/api/auth/login/route.ts +++ b/frontend/prismtask/app/api/auth/login/route.ts @@ -1,6 +1,7 @@ import { NextRequest, NextResponse } from 'next/server'; import fs from 'fs'; import path from 'path'; +import { SESSION_COOKIE } from '@/lib/auth'; interface User { id: number; @@ -9,22 +10,34 @@ interface User { } const USERS_FILE = path.join(process.cwd(), 'data', 'users.json'); +const EIGHT_HOURS_SECONDS = 60 * 60 * 8; function readUsers(): User[] { if (!fs.existsSync(USERS_FILE)) return []; try { - return JSON.parse(fs.readFileSync(USERS_FILE, 'utf-8')); + const parsed = JSON.parse(fs.readFileSync(USERS_FILE, 'utf-8')); + return Array.isArray(parsed) ? (parsed as User[]) : []; } catch { return []; } } -// POST /api/auth/login +// POST /api/auth/login — validate credentials and set session cookie. export async function POST(req: NextRequest) { - const { email, password } = await req.json(); + let payload: unknown; + try { + payload = await req.json(); + } catch { + return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 }); + } + + const { email, password } = (payload ?? {}) as Record; - if (!email || !password) { - return NextResponse.json({ error: 'Email and password are required' }, { status: 400 }); + if (typeof email !== 'string' || typeof password !== 'string' || !email || !password) { + return NextResponse.json( + { error: 'Email and password are required' }, + { status: 400 } + ); } const users = readUsers(); @@ -36,20 +49,27 @@ export async function POST(req: NextRequest) { return NextResponse.json({ error: 'Invalid email or password' }, { status: 401 }); } - // Set a simple session cookie so /tasks knows who is logged in const response = NextResponse.json({ success: true, email: user.email }); - response.cookies.set('prismtask_user', user.email, { + response.cookies.set(SESSION_COOKIE, user.email, { httpOnly: true, + sameSite: 'lax', // mitigates CSRF on state-changing routes (spec R-02) path: '/', - maxAge: 60 * 60 * 8, // 8 hours + maxAge: EIGHT_HOURS_SECONDS, }); return response; } -// POST /api/auth/logout +// DELETE /api/auth/login — clear session cookie (log out). export async function DELETE() { const response = NextResponse.json({ success: true }); - response.cookies.delete('prismtask_user'); + // Overwrite with an immediately-expiring cookie so every cookie jar + // (including those that ignore bare `delete`) clears the session. + response.cookies.set(SESSION_COOKIE, '', { + httpOnly: true, + sameSite: 'lax', + path: '/', + maxAge: 0, + }); return response; } diff --git a/frontend/prismtask/app/api/tasks/[id]/route.ts b/frontend/prismtask/app/api/tasks/[id]/route.ts new file mode 100644 index 0000000..fdfdbac --- /dev/null +++ b/frontend/prismtask/app/api/tasks/[id]/route.ts @@ -0,0 +1,101 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { readTasks, writeTasks, Task } from '@/lib/taskStore'; +import { requireAuth } from '@/lib/auth'; + +type Params = { params: Promise<{ id: string }> }; + +const VALID_PRIORITIES = ['High', 'Medium', 'Low'] as const; +type Priority = (typeof VALID_PRIORITIES)[number]; + +function isPriority(value: unknown): value is Priority { + return typeof value === 'string' && (VALID_PRIORITIES as readonly string[]).includes(value); +} + +// PATCH /api/tasks/[id] — toggle completed or update any field +export async function PATCH(req: NextRequest, { params }: Params) { + const unauthorized = requireAuth(req); + if (unauthorized) return unauthorized; + + const { id } = await params; + const taskId = Number.parseInt(id, 10); + + // Unknown / non-numeric ids map to 404, matching spec test T-04. + if (!Number.isInteger(taskId)) { + return NextResponse.json({ error: 'task not found' }, { status: 404 }); + } + + const tasks = readTasks(); + const index = tasks.findIndex((t) => t.id === taskId); + + if (index === -1) { + return NextResponse.json({ error: 'task not found' }, { status: 404 }); + } + + let body: unknown; + try { + body = await req.json(); + } catch { + return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 }); + } + + const patch = (body ?? {}) as Partial; + + // Validate any supplied fields; reject bad types rather than silently accept. + if (patch.title !== undefined) { + if (typeof patch.title !== 'string' || patch.title.trim() === '') { + return NextResponse.json({ error: 'title must be a non-empty string' }, { status: 400 }); + } + patch.title = patch.title.trim(); + } + if (patch.priority !== undefined && !isPriority(patch.priority)) { + return NextResponse.json( + { error: 'priority must be High, Medium, or Low' }, + { status: 400 } + ); + } + if (patch.completed !== undefined && typeof patch.completed !== 'boolean') { + return NextResponse.json({ error: 'completed must be boolean' }, { status: 400 }); + } + if (patch.description !== undefined && typeof patch.description !== 'string') { + return NextResponse.json({ error: 'description must be a string' }, { status: 400 }); + } + if (patch.dueDate !== undefined && typeof patch.dueDate !== 'string') { + return NextResponse.json({ error: 'dueDate must be a string' }, { status: 400 }); + } + + // id, createdAt are server-owned and must never be overwritten by a PATCH. + tasks[index] = { + ...tasks[index], + ...patch, + id: taskId, + createdAt: tasks[index].createdAt, + }; + writeTasks(tasks); + + return NextResponse.json(tasks[index]); +} + +// DELETE /api/tasks/[id] — remove a task +export async function DELETE(req: NextRequest, { params }: Params) { + const unauthorized = requireAuth(req); + if (unauthorized) return unauthorized; + + const { id } = await params; + const taskId = Number.parseInt(id, 10); + + if (!Number.isInteger(taskId)) { + return NextResponse.json({ error: 'task not found' }, { status: 404 }); + } + + const tasks = readTasks(); + const index = tasks.findIndex((t) => t.id === taskId); + + if (index === -1) { + return NextResponse.json({ error: 'task not found' }, { status: 404 }); + } + + const [removed] = tasks.splice(index, 1); + writeTasks(tasks); + + return NextResponse.json(removed); +} diff --git a/frontend/prismtask/app/api/tasks/route.ts b/frontend/prismtask/app/api/tasks/route.ts index 66a6e39..589ed42 100644 --- a/frontend/prismtask/app/api/tasks/route.ts +++ b/frontend/prismtask/app/api/tasks/route.ts @@ -1,29 +1,58 @@ import { NextRequest, NextResponse } from 'next/server'; import { readTasks, writeTasks, nextId, Task } from '@/lib/taskStore'; +import { requireAuth } from '@/lib/auth'; + +const VALID_PRIORITIES = ['High', 'Medium', 'Low'] as const; +type Priority = (typeof VALID_PRIORITIES)[number]; + +function isPriority(value: unknown): value is Priority { + return typeof value === 'string' && (VALID_PRIORITIES as readonly string[]).includes(value); +} // GET /api/tasks — return all tasks, optional ?priority=High filter export async function GET(req: NextRequest) { + const unauthorized = requireAuth(req); + if (unauthorized) return unauthorized; + const tasks = readTasks(); const priority = req.nextUrl.searchParams.get('priority'); - const result = priority - ? tasks.filter((t) => t.priority === priority) - : tasks; + if (priority !== null && !isPriority(priority)) { + return NextResponse.json( + { error: 'priority must be High, Medium, or Low' }, + { status: 400 } + ); + } + + const result = priority ? tasks.filter((t) => t.priority === priority) : tasks; return NextResponse.json(result); } // POST /api/tasks — create a new task export async function POST(req: NextRequest) { - const body = await req.json(); + const unauthorized = requireAuth(req); + if (unauthorized) return unauthorized; + + let body: unknown; + try { + body = await req.json(); + } catch { + return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 }); + } - const { title, description = '', priority = 'Low', dueDate = '' } = body; + const { + title, + description = '', + priority = 'Low', + dueDate = '', + } = (body ?? {}) as Record; - if (!title || typeof title !== 'string' || title.trim() === '') { + if (typeof title !== 'string' || title.trim() === '') { return NextResponse.json({ error: 'title is required' }, { status: 400 }); } - if (!['High', 'Medium', 'Low'].includes(priority)) { + if (!isPriority(priority)) { return NextResponse.json( { error: 'priority must be High, Medium, or Low' }, { status: 400 } @@ -35,10 +64,10 @@ export async function POST(req: NextRequest) { const newTask: Task = { id: nextId(tasks), title: title.trim(), - description, + description: typeof description === 'string' ? description : '', priority, completed: false, - dueDate, + dueDate: typeof dueDate === 'string' ? dueDate : '', createdAt: new Date().toISOString(), }; diff --git a/frontend/prismtask/app/globals.css b/frontend/prismtask/app/globals.css index 3ea3242..f2fca2f 100644 --- a/frontend/prismtask/app/globals.css +++ b/frontend/prismtask/app/globals.css @@ -80,4 +80,16 @@ body::before { opacity: 0.8; animation: shine 5s linear infinite; -} \ No newline at end of file +} + +/* + * Accessibility: when the user has requested reduced motion at the OS level, + * freeze the shard float + shine animations. The decorative layer stays + * visible but stops moving, which satisfies spec §4.2.8 and risk R-06. + */ +@media (prefers-reduced-motion: reduce) { + .prism-shard, + .prism-shard::after { + animation: none !important; + } +} diff --git a/frontend/prismtask/app/layout.tsx b/frontend/prismtask/app/layout.tsx index 976eb90..d99238f 100644 --- a/frontend/prismtask/app/layout.tsx +++ b/frontend/prismtask/app/layout.tsx @@ -1,20 +1,10 @@ -import type { Metadata } from "next"; -import { Geist, Geist_Mono } from "next/font/google"; -import "./globals.css"; - -const geistSans = Geist({ - variable: "--font-geist-sans", - subsets: ["latin"], -}); - -const geistMono = Geist_Mono({ - variable: "--font-geist-mono", - subsets: ["latin"], -}); +import type { Metadata } from 'next'; +import './globals.css'; export const metadata: Metadata = { - title: "Create Next App", - description: "Generated by create next app", + title: 'PrismTask', + description: + 'PrismTask — a private, daily-driver task manager you actually want to open every day.', }; export default function RootLayout({ @@ -23,10 +13,7 @@ export default function RootLayout({ children: React.ReactNode; }>) { return ( - + {children} ); diff --git a/frontend/prismtask/app/page.tsx b/frontend/prismtask/app/page.tsx index 5f43a63..9c79911 100644 --- a/frontend/prismtask/app/page.tsx +++ b/frontend/prismtask/app/page.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useMemo, useState, useEffect } from 'react'; +import { useState, useEffect, useRef } from 'react'; import { useRouter } from 'next/navigation'; export default function Home() { @@ -13,23 +13,67 @@ export default function Home() { const [mouse, setMouse] = useState({ x: 0, y: 0 }); const [screen, setScreen] = useState({ w: 0, h: 0 }); + // When true, disable parallax/repulsion listeners entirely. Covers both + // `prefers-reduced-motion` (spec §4.2.8) and the tab-hidden case (risk R-06). + const [motionPaused, setMotionPaused] = useState(false); + + // Track live state in a ref so the shared mousemove handler can bail fast. + const motionPausedRef = useRef(false); + useEffect(() => { + motionPausedRef.current = motionPaused; + }, [motionPaused]); + useEffect(() => { + const mq = + typeof window !== 'undefined' && window.matchMedia + ? window.matchMedia('(prefers-reduced-motion: reduce)') + : null; + + const syncMotion = () => { + const reduced = mq?.matches ?? false; + const hidden = + typeof document !== 'undefined' && document.visibilityState === 'hidden'; + setMotionPaused(reduced || hidden); + }; + syncMotion(); + const handleMove = (e: MouseEvent) => { + if (motionPausedRef.current) return; const x = (e.clientX / window.innerWidth - 0.5) * 30; const y = (e.clientY / window.innerHeight - 0.5) * 30; setMouse({ x, y }); }; const handleResize = () => setScreen({ w: window.innerWidth, h: window.innerHeight }); + handleResize(); window.addEventListener('mousemove', handleMove); window.addEventListener('resize', handleResize); + document.addEventListener('visibilitychange', syncMotion); + mq?.addEventListener?.('change', syncMotion); + return () => { window.removeEventListener('mousemove', handleMove); window.removeEventListener('resize', handleResize); + document.removeEventListener('visibilitychange', syncMotion); + mq?.removeEventListener?.('change', syncMotion); }; }, []); - const shards = useMemo(() => { + // Shards are randomised once. Generated via useState's lazy initializer so + // the render function stays pure (satisfies `react-hooks/purity`) and no + // cascading re-render is triggered from inside an effect. + type Shard = { + width: number; + height: number; + top: number; + left: number; + rotate: number; + opacity: number; + depth: number; + color1: string; + color2: string; + }; + const [shards] = useState(() => { const colors = [ ['rgba(0,200,255,0.5)', 'rgba(0,120,255,0.5)'], ['rgba(180,0,255,0.5)', 'rgba(120,0,255,0.5)'], @@ -49,7 +93,7 @@ export default function Home() { color2: c[1], }; }); - }, []); + }); function handleLogin() { if (loading) return; @@ -78,43 +122,47 @@ export default function Home() { return (
- - {/* Shard background — pointer-events-none so shards never block clicks */} -
+ {/* Shard background — pointer-events-none so shards never block clicks. */} +