Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 23 additions & 18 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
# Task Flow — Task Management App

Task Flow is a task management app I'm building on top of a GraphQL API — browse, create, update, and organize tasks on a kanban-style dashboard.
![Task Flow running on desktop, laptop, tablet, and phone](src/assets/readme/readme.png)

Task Flow is a complete task management app built on a GraphQL API: browse, create, update, and organize tasks on a kanban-style dashboard.

## Live Demo

Expand All @@ -10,8 +12,8 @@ _Live app and video walkthrough coming soon._

## Screenshots

<!-- TODO: add screenshots or GIFs of the working app once Phase 2/3 are done -->
<!-- Tip: record a short GIF of drag-and-drop and the create/edit flow, that sells the project fastest -->
<!-- TODO before submission: per-feature captures — board, list view, create/edit modal, filters, settings -->
<!-- Tip: a short GIF of the create flow (new card scrolling into view and animating in) sells it fastest -->

## Tech Stack

Expand All @@ -26,7 +28,7 @@ _Live app and video walkthrough coming soon._

## Setup & Running Locally

Requires Node 24.14.1+ (see `.nvmrc`) the first Node 24 release whose bundled npm satisfies the `min-release-age` support floor (the feature landed in npm 11.10.0).
Requires Node 24.14.1+ (see `.nvmrc`), the first Node 24 release whose bundled npm satisfies the `min-release-age` support floor (the feature landed in npm 11.10.0).

```bash
git clone https://github.com/Alejandroq12/task-flow.git
Expand All @@ -45,13 +47,13 @@ The app runs at `http://localhost:5173`.
| `API_URL` | GraphQL endpoint of the project API |
| `API_TOKEN` | Personal access token (attached server-side, see below) |

Real values live in `.env.local`, which is gitignored — never commit tokens. Both variables are also required by `npm run codegen`.
Real values live in `.env.local`, which is gitignored. Never commit tokens. `npm run codegen` also requires both variables.

> **Security note:** the token is deliberately not `VITE_`-prefixed. Vite inlines `VITE_*` variables into the public JS bundle, where anyone could extract them. Instead, the app calls the relative path `/graphql`, and the dev server proxies it to the real API, attaching the `Authorization` header in Node (see `vite.config.ts`). The token never reaches the browser. A deployed build would need the same proxy as a serverless functionthe static bundle alone cannot, and must not, carry the token — and that proxy would itself need caller authentication and rate limiting, since an open proxy holding a shared token is effectively an open relay to the API. `API_URL` must be `https` (enforced at startup); the token never travels over plaintext.
> **Security note:** the token is deliberately not `VITE_`-prefixed. Vite inlines `VITE_*` variables into the public JS bundle, where anyone could extract them. Instead, the app calls the relative path `/graphql`, and the dev server proxies it to the real API, attaching the `Authorization` header in Node (see `vite.config.ts`). The token never reaches the browser. A deployed build would need the same proxy as a serverless function; the static bundle alone cannot, and must not, carry the token. That proxy would itself need caller authentication and rate limiting, since an open proxy holding a shared token is effectively an open relay to the API. `API_URL` must be `https` (enforced at startup); the token never travels over plaintext.

### Deploying (Vercel)

The static bundle holds no API URL or token, so the deployment carries its own Node-side proxy: `api/graphql.ts` is a Vercel serverless function that forwards `POST /api/graphql` to the real API with the Bearer header attached server-side, and `vercel.json` rewrites `/graphql` to it (so the client code is identical in every environment) plus falls back to `index.html` for client-side routes. Setup: add `API_URL` and `API_TOKEN` (same names as `.env.local`) under Project → Settings → Environment Variables, then redeploy. **Accepted risk for this challenge:** the function has no caller authentication or rate limiting, so the deployed URL is an open relay to the challenge API (see the security note above) acceptable for a graded demo holding a scoped challenge token, not for production.
The static bundle holds no API URL or token, so the deployment carries its own Node-side proxy. `api/graphql.ts` is a Vercel serverless function that forwards `POST /api/graphql` to the real API with the Bearer header attached server-side. `vercel.json` rewrites `/graphql` to it (so the client code is identical in every environment) and falls back to `index.html` for client-side routes. Setup: add `API_URL` and `API_TOKEN` (same names as `.env.local`) under Project → Settings → Environment Variables, then redeploy. **Accepted risk for this challenge:** the function has no caller authentication or rate limiting, so the deployed URL is an open relay to the challenge API (see the security note above). That's acceptable for a graded demo holding a scoped challenge token, not for production.

### Available scripts

Expand Down Expand Up @@ -114,25 +116,28 @@ src/
- [x] Delete task — 'Delete Task?' confirmation via the options menu, deleteTask by id, success/failure notifications
- [x] View toggle & My Task — grid/list layouts on both views (list = the mockup's grouped table with due-date row indicators), switched by the toolbar icons on desktop and by the Dashboard/Task tabs on mobile; My Task filters to tasks assigned to the logged-in user via the profile query
- [x] Search & filter — the header search and five filter chips (status, estimate, tags, due date, owner) live in URL search params, combine freely, and show a dedicated empty-results state when nothing matches
- [x] User settings page — reached from a Settings sidebar item — rendered with the same NavLink anatomy as Dashboard and My Task; the design system documents its SidebarItem as an abstract component, which is what sanctions adding a third itemand by clicking the header avatar; /settings renders the profile query (full name, email, type chip, created/updated dates) in an invented card design built from the app's own tokens; the requirement's Position field does not exist on the API's User type (verified by introspection), so the row states that instead of fabricating a value
- [x] User settings page — reached from a Settings sidebar item (same NavLink anatomy as Dashboard and My Task; the design system documents its SidebarItem as an abstract component, which sanctions adding a third item) and by clicking the header avatar; /settings renders the profile query (full name, email, type chip, created/updated dates) in an invented card design built from the app's own tokens; the requirement's Position field does not exist on the API's User type (verified by introspection), so the row says so instead of fabricating a value

### Bonus points attempted
## Bonus Points

<!-- TODO: list which bonus features I tackled, if any -->
- **Total count of tasks by column** — board column headers and list group headers both carry live counts.
- **Layout toggle (columns ↔ list)** — the desktop icon switcher and the mobile Dashboard/Task tabs drive one shared selection that survives navigation and resizes.
- **Due-date colors** — green on time, amber under two days, red overdue: one rule (`dueInfo` in `task-display.ts`) drives the card date chips, the list view's row indicators, and the list date text. The mockup only shows the red/neutral chip states; the requirement asks for three colors, and requirements outrank mockups.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Correct the due-date boundary wording.

dueInfo uses the amber tone when dayDiff <= 2. This includes tasks due today, tomorrow, and exactly two days from now. Replace “under two days” with “within two days” or document the exact boundary.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@README.md` at line 123, Update the README due-date colors description for
dueInfo to say amber applies “within two days” (or explicitly state the
inclusive dayDiff <= 2 boundary), while preserving the existing green and red
descriptions.

- **Add-task animation** — after a create, the board refetches, scrolls the new card into view, and the card fade-rises in. React reconciles by task id, so only the genuinely new card mounts and animates, never the whole board. Under reduced-motion preferences, the scroll is instant and the entrance animation is disabled.

## Additional Notes

- **Generated GraphQL code is committed on purpose.** `src/graphql/generated/` (output of `npm run codegen`) is checked into git so CI can typecheck and build without holding the API token. Regenerate after changing any query/mutation; never edit by hand.
- **Quality gates are CI-enforced, not hook-enforced.** There are deliberately no git hooks (husky/lint-staged): CI runs format check, lint, typecheck, tests, and build on every PR, and the same scripts run locally on demand. Hooks can be added later if commit-time enforcement proves necessary.
- **The settings Position row says "Not provided by the API."** The requirement lists Position among the user fields, but the User type has no such field (introspection: fullName, email, type, avatar, createdAt, updatedAt). The row is rendered so the requirement's shape is visible, with an honest value instead of an invented one.
- **Filter state lives in the URL.** Search and filters are `?q=…&status=…` search params, not component state: filtered views are shareable/bookmarkable, survive reloads, and search-params changes don't remount the page (the error boundary keys on pathname only). Three observed API behaviors are documented rather than papered over: name matching is a **case-sensitive** substring (verified: `icket` matches `Ticket5`, `ticket` does not); `dueDate` filters by **exact timestamp** equality (this app writes all due dates at noon UTC, so day-level filtering works for tasks it created); and `ownerId` is accepted but **ignored by the server** (a nonexistent id returns the full task list) — the param is still sent as required, and the owner filter additionally applies client-side against the task's `creator.id` so the control does what it says.
- **Quality gates are CI-enforced, not hook-enforced.** The repo deliberately has no git hooks (husky/lint-staged): CI runs format check, lint, typecheck, tests, and build on every PR, and the same scripts run locally on demand. Hooks can be added later if commit-time enforcement proves necessary.
- **The settings Position row says "Not provided by the API."** The requirement lists Position among the user fields, but the User type has no such field (introspection: fullName, email, type, avatar, createdAt, updatedAt). The row still renders so the requirement's shape is visible, with an honest value instead of an invented one.
- **Filter state lives in the URL.** Search and filters are `?q=…&status=…` search params, not component state: filtered views are shareable/bookmarkable and survive reloads, and search-params changes don't remount the page (the error boundary keys on pathname only). Three observed API behaviors are documented rather than papered over: name matching is a **case-sensitive** substring (verified: `icket` matches `Ticket5`, `ticket` does not); `dueDate` filters by **exact timestamp** equality (this app writes all due dates at noon UTC, so day-level filtering works for tasks it created); and `ownerId` is accepted but **ignored by the server** (a nonexistent id returns the full task list). The param is still sent as required, and the owner filter also applies client-side against the task's `creator.id`, so the control does what it says.
- **Tag labels derive from the API enum.** The mockups show sample tag texts that contradict each other across surfaces (the same tag renders "IOS APP" on cards but "IOS" in the tag menu, "ANDROID" on cards but "Android App" in the menu). Since the API's TaskTag enum is the real domain, labels derive from the enum values (IOS, ANDROID, REACT, NODE JS, RAILS) and are identical everywhere.
- **List group-header hover icons are omitted.** One mockup group header shows +/… icons; they have no behavior behind them (non-working UI, same principle as the bell).
- **List rows have an actions column the mockup lacks.** The requirement ties update/delete to the options icon, and a list-only user would otherwise have no way to reach them — requirements outrank mockups, so each row ends with the same options menu the cards use.
- **List-view row borders follow the due date.** The mockup's task table shows rows with identical dates but different left-border colorsan inconsistency the team acknowledged in Slack ("we use to have those in real projects"). Per the team's guidance that the border is a due-date indicator, the rule implemented is: overdue = red (primary), due within two days = amber (tertiary), later = green (secondary).
- **The header bell is THE notification system.** Mutation successes and failures are recorded to a notification center the bell opens (unread dot, ten-entry history, marked read on open); failures additionally surface as inline alerts in the dialog that caused them, so errors are impossible to miss. Transient toasts were built first and deliberately removed — two presentations of the same event stream duplicated a function; the bell is the one the Figma shows. The panel's own design has no mockup, so it reuses the app's menu anatomy. The card metric icons below stay omitted because their data provably does not exist in the schema the bell's does.
- **List rows have an actions column the mockup lacks.** The requirement ties update/delete to the options icon, and a list-only user would otherwise have no way to reach them. Requirements outrank mockups, so each row ends with the same options menu the cards use.
- **List-view row borders follow the due date.** The mockup's task table shows rows with identical dates but different left-border colors, an inconsistency the team acknowledged in Slack ("we use to have those in real projects"). Per the team's guidance that the border is a due-date indicator, the rule is: overdue = red (primary), due within two days = amber (tertiary), later = green (secondary).
- **The header bell is THE notification system.** Mutation successes and failures are recorded to a notification center the bell opens (unread dot, ten-entry history, marked read on open); failures also surface as inline alerts in the dialog that caused them, so errors are impossible to miss. Transient toasts were built first and deliberately removed. Two presentations of the same event stream duplicated a function; the bell is the one the Figma shows. The panel's own design has no mockup, so it reuses the app's menu anatomy. The card metric icons below stay omitted because their data provably does not exist in the schema; the bell's does.
- **Card attachment/fork/comment icons are omitted.** The Figma shows those metrics on task cards, but the API's Task type exposes no fields for them. Per mentor guidance not to expose non-working UI, the icons are removed until the schema provides the data; the SVGs live in git history for easy reintroduction.
- **Node 24 is a hard requirement** — `.npmrc` sets `engine-strict=true`, so `npm install` fails fast on older Node instead of warning.
- **A11y deviation from the design, flagged and recommended per mentor guidance:** the Figma's active-tab red (`primary-4`, `#da584b`) on the dark surface measures ≈3.5:1 below WCAG AA's 4.5:1 for 15px text. Following the design team's process (flag + recommend), the active label uses `primary-3` (`#e27d73`) one step up the design system's own red scale measuring ≈4.7:1 (≈4.5:1 worst-case over the 5% gradient wash). The indicator bar stays `primary-4` (non-text graphic, 3:1 rule, passes). The active state is also conveyed non-visually via `aria-current="page"`.
- **Node 24 is a hard requirement.** `.npmrc` sets `engine-strict=true`, so `npm install` fails fast on older Node instead of warning.
- **A11y deviation from the design, flagged and recommended per mentor guidance:** the Figma's active-tab red (`primary-4`, `#da584b`) on the dark surface measures ≈3.5:1, below WCAG AA's 4.5:1 for 15px text. Following the design team's process (flag + recommend), the active label uses `primary-3` (`#e27d73`), one step up the design system's own red scale, which measures ≈4.7:1 (≈4.5:1 worst-case over the 5% gradient wash). The indicator bar stays `primary-4` (non-text graphic, 3:1 rule, passes). The active state is also conveyed non-visually via `aria-current="page"`.

<!-- TODO: anything else worth mentioning — known limitations, things I'd want feedback on, etc. -->
Binary file added src/assets/readme/readme.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
19 changes: 17 additions & 2 deletions src/features/tasks/CreateTaskModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,31 @@ import { TaskForm } from '@/features/tasks/TaskForm'
import { useNotify } from '@/components/ui/notifications-context'
import type { CreateTaskInput } from '@/graphql/generated/graphql'

function scrollToTask(id: string, attempts = 10) {
const card = document.querySelector(`[data-task-id="${id}"]`)
Comment on lines +9 to +10

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Make the task identity contract consistent across layouts.

scrollToTask searches for [data-task-id="${id}"]. The board card exposes this attribute, but the list view's TaskRow does not. When a user creates a task in list layout, the refresh succeeds but the polling loop never finds the row, so the new task is not scrolled into view. Add data-task-id={task.id} to the TaskRow root or use a shared anchor in both layouts. The add-task control is available in list layout, so this path is reachable. (raw.githubusercontent.com)

Proposed contract fix
--- a/src/features/tasks/TaskList.tsx
+++ b/src/features/tasks/TaskList.tsx
@@
-      <div className="flex">
+      <div data-task-id={task.id} className="flex">
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/features/tasks/CreateTaskModal.tsx` around lines 9 - 10, Make the task
identity contract consistent by adding the data-task-id attribute with task.id
to the TaskRow root used by the list layout, so scrollToTask can locate newly
created tasks. Keep the existing board-card attribute and scrollToTask behavior
unchanged.

Source: MCP tools

if (card !== null) {
card.scrollIntoView({ block: 'center' })
return
}
if (attempts > 0) {
setTimeout(() => {
scrollToTask(id, attempts - 1)
}, 100)
}
}

export function CreateTaskModal({ onClose }: { onClose: () => void }) {
const queryClient = useQueryClient()
const notify = useNotify()

const createTask = useMutation({
mutationFn: (input: CreateTaskInput) => graphqlClient.request(CreateTaskDocument, { input }),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ['tasks'] })
onSuccess: (data) => {
notify('Task created.', 'success')
onClose()
void queryClient.invalidateQueries({ queryKey: ['tasks'] }).then(() => {
scrollToTask(data.createTask.id)
})
},
onError: () => {
notify('The task could not be created.', 'error')
Expand Down
23 changes: 18 additions & 5 deletions src/features/tasks/TaskCard.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,30 @@
import avatarUrl from '@/assets/avatar.png'
import { avatarSrc } from '@/lib/avatar'
import { AlarmIcon } from '@/features/tasks/icons'
import { dueInfo, pointsLabel, TAG_META, tagToneClasses } from '@/features/tasks/task-display'
import {
dueInfo,
pointsLabel,
TAG_META,
tagToneClasses,
type DueTone,
} from '@/features/tasks/task-display'
import { TaskActions } from '@/features/tasks/TaskActions'
import type { ApiTask } from '@/features/tasks/types'

const toneChip: Record<DueTone, string> = {
primary: 'bg-primary-4/10 text-primary-4',
tertiary: 'bg-tertiary-4/10 text-tertiary-4',
secondary: 'bg-secondary-4/10 text-secondary-4',
}

export function TaskCard({ task }: { task: ApiTask }) {
const due = dueInfo(task.dueDate)

return (
<article className="flex flex-col gap-4 rounded-lg bg-neutral-4 p-4">
<article
data-task-id={task.id}
className="flex animate-card-in flex-col gap-4 rounded-lg bg-neutral-4 p-4"
>
<div className="flex h-8 items-center gap-2">
<h3 className="min-w-0 flex-1 truncate text-body-l font-semibold text-neutral-1">
{task.name}
Expand All @@ -21,9 +36,7 @@ export function TaskCard({ task }: { task: ApiTask }) {
{pointsLabel(task.pointEstimate)}
</span>
<span
className={`flex items-center gap-2 rounded px-4 py-1 text-body-m font-semibold ${
due.overdue ? 'bg-primary-4/10 text-primary-4' : 'bg-neutral-2/10 text-neutral-1'
}`}
className={`flex items-center gap-2 rounded px-4 py-1 text-body-m font-semibold ${toneChip[due.tone]}`}
>
<AlarmIcon className="size-6" />
{due.label}
Expand Down
10 changes: 7 additions & 3 deletions src/features/tasks/TaskList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,12 @@ const toneBorder: Record<DueTone, string> = {
secondary: 'border-l-secondary-4',
}

const toneText: Record<DueTone, string> = {
primary: 'text-primary-3',
tertiary: 'text-tertiary-4',
secondary: 'text-secondary-4',
}

const cellBase = 'flex h-14 items-center border border-neutral-3 bg-neutral-4 py-1'
const nameWidth = 'min-w-60 flex-1'
const tagsWidth = 'w-42 shrink-0'
Expand Down Expand Up @@ -77,9 +83,7 @@ function TaskRow({ task, index }: { task: ApiTask; index: number }) {
</span>
</div>
<div className={`${cellBase} ${dueWidth} pr-4 pl-2`}>
<span className={`text-body-m ${due.overdue ? 'text-primary-3' : 'text-neutral-1'}`}>
{titleCase(due.label)}
</span>
<span className={`text-body-m ${toneText[due.tone]}`}>{titleCase(due.label)}</span>
</div>
<div className={`${cellBase} ${actionsWidth} justify-center`}>
<TaskActions task={task} />
Expand Down
Loading