diff --git a/README.md b/README.md index 621889f..1098753 100644 --- a/README.md +++ b/README.md @@ -104,7 +104,7 @@ src/ - [x] Initial setup (folder structure, routing, styles solution, linting/formatting, error boundary, tests, CI) - [x] Dashboard UI (static): sidebar with mobile drawer, header, toolbar, five status columns, task cards -- [ ] API connection — fetch tasks, loading/error/empty states +- [x] API connection — fetch tasks into their status columns, with loading skeleton, failure alert + retry, and empty state - [ ] Create task - [ ] Update task - [ ] Delete task @@ -119,6 +119,7 @@ src/ - **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. +- **Card reaction icons are decorative.** The Figma shows attachment/fork/comment metrics on task cards, but the API exposes no such fields — the icons are kept for design fidelity without fabricating counts. - **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"`. diff --git a/codegen.ts b/codegen.ts index 92fbc08..fbb98b8 100644 --- a/codegen.ts +++ b/codegen.ts @@ -27,7 +27,7 @@ const config: CodegenConfig = { './src/graphql/generated/': { preset: 'client', // Required by verbatimModuleSyntax: emit `import type` for type-only symbols. - config: { useTypeImports: true }, + config: { useTypeImports: true, scalars: { DateTime: 'string' } }, }, }, } diff --git a/src/app/router.test.tsx b/src/app/router.test.tsx index 55f9d8f..cf2c7b3 100644 --- a/src/app/router.test.tsx +++ b/src/app/router.test.tsx @@ -1,9 +1,25 @@ -import { describe, expect, it } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { render, screen, within } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { createMemoryRouter, RouterProvider } from 'react-router' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { routes } from '@/app/router' +import { makeFixtureTasks, tasksRequestMock } from '@/test/fixtures' + +vi.mock('@/lib/graphql-client', () => ({ + graphqlClient: { request: vi.fn() }, +})) + +beforeEach(() => { + vi.useFakeTimers({ toFake: ['Date'] }) + vi.setSystemTime(new Date('2026-08-06T12:00:00Z')) + tasksRequestMock().mockReset() + tasksRequestMock().mockResolvedValue({ tasks: makeFixtureTasks() }) +}) + +afterEach(() => { + vi.useRealTimers() +}) // Fresh QueryClient per test: no cache leaks between tests, no retries // slowing failure cases down. @@ -64,25 +80,28 @@ describe('sidebar navigation', () => { }) describe('dashboard main content', () => { - it('renders the five required status columns', () => { + it('renders the five required status columns with fetched tasks', async () => { renderAt('/') - for (const title of ['Backlog', 'To Do', 'In Progress', 'Done', 'Cancelled']) { + expect(await screen.findByRole('heading', { level: 2, name: /backlog/i })).toBeInTheDocument() + for (const title of ['To Do', 'In Progress', 'Done', 'Cancelled']) { expect( screen.getByRole('heading', { level: 2, name: new RegExp(title, 'i') }), ).toBeInTheDocument() } + expect(screen.getByRole('heading', { level: 3, name: /slack/i })).toBeInTheDocument() }) - it('renders a task card with its required fields', () => { + it('renders a task card with its required fields', async () => { renderAt('/') - const card = screen.getByRole('heading', { level: 3, name: /twitter/i }).closest('article') + const card = (await screen.findByRole('heading', { level: 3, name: /twitter/i })).closest( + 'article', + ) if (!card) throw new Error('expected the Twitter card to render inside an
') const scoped = within(card) - expect(scoped.getByText(/3 pts/i)).toBeInTheDocument() + expect(scoped.getByText(/8 pts/i)).toBeInTheDocument() expect(scoped.getByText(/yesterday/i)).toBeInTheDocument() - expect(scoped.getByText(/ios app/i)).toBeInTheDocument() - expect(scoped.getByText(/android/i)).toBeInTheDocument() - expect(scoped.getByRole('img', { name: /assignee/i })).toBeInTheDocument() + expect(scoped.getByText('REACT')).toBeInTheDocument() + expect(scoped.getByRole('img', { name: /unassigned/i })).toBeInTheDocument() expect(scoped.getByRole('img', { name: /task options/i })).toBeInTheDocument() }) @@ -103,6 +122,27 @@ describe('dashboard main content', () => { }) }) +describe('tasks query states', () => { + it('shows a loading indicator while fetching', () => { + tasksRequestMock().mockImplementation(() => new Promise(() => undefined)) + renderAt('/') + expect(screen.getByRole('status')).toHaveTextContent(/loading tasks/i) + }) + + it('indicates when the query has failed and offers a retry', async () => { + tasksRequestMock().mockRejectedValue(new Error('network down')) + renderAt('/') + expect(await screen.findByRole('alert')).toHaveTextContent(/something went wrong/i) + expect(screen.getByRole('button', { name: /try again/i })).toBeInTheDocument() + }) + + it('shows an empty state when there are no results', async () => { + tasksRequestMock().mockResolvedValue({ tasks: [] }) + renderAt('/') + expect(await screen.findByText(/no tasks found/i)).toBeInTheDocument() + }) +}) + describe('header', () => { it('renders a controlled search input', async () => { const user = userEvent.setup() diff --git a/src/features/tasks/Dashboard.tsx b/src/features/tasks/Dashboard.tsx index 21168eb..c84f96c 100644 --- a/src/features/tasks/Dashboard.tsx +++ b/src/features/tasks/Dashboard.tsx @@ -1,13 +1,12 @@ import { Toolbar } from '@/features/tasks/Toolbar' -import { TaskBoard } from '@/features/tasks/TaskBoard' -import { sampleColumns } from '@/features/tasks/sample-tasks' +import { TasksView } from '@/features/tasks/TasksView' export function Dashboard() { return (

Dashboard

- +
) } diff --git a/src/features/tasks/MyTask.tsx b/src/features/tasks/MyTask.tsx index d8483db..6776a7e 100644 --- a/src/features/tasks/MyTask.tsx +++ b/src/features/tasks/MyTask.tsx @@ -1,13 +1,12 @@ import { Toolbar } from '@/features/tasks/Toolbar' -import { TaskBoard } from '@/features/tasks/TaskBoard' -import { sampleColumns } from '@/features/tasks/sample-tasks' +import { TasksView } from '@/features/tasks/TasksView' export function MyTask() { return (

My Task

- +
) } diff --git a/src/features/tasks/TaskCard.tsx b/src/features/tasks/TaskCard.tsx index e271353..b05b399 100644 --- a/src/features/tasks/TaskCard.tsx +++ b/src/features/tasks/TaskCard.tsx @@ -1,13 +1,16 @@ import avatarUrl from '@/assets/avatar.png' import { AlarmIcon, AttachIcon, ChatIcon, DotsIcon, ForkIcon } from '@/features/tasks/icons' -import type { Task, TagTone } from '@/features/tasks/types' +import { dueInfo, pointsLabel, TAG_META, type TagTone } from '@/features/tasks/task-display' +import type { ApiTask } from '@/features/tasks/types' const tagToneClasses: Record = { secondary: 'bg-secondary-4/10 text-secondary-4', tertiary: 'bg-tertiary-4/10 text-tertiary-4', + neutral: 'bg-neutral-2/10 text-neutral-1', } -export function TaskCard({ task }: { task: Task }) { +export function TaskCard({ task }: { task: ApiTask }) { + const due = dueInfo(task.dueDate) return (
@@ -19,38 +22,38 @@ export function TaskCard({ task }: { task: Task }) {
- {task.points} Pts + + {pointsLabel(task.pointEstimate)} + - {task.dueLabel} + {due.label}
- {task.tags.map(({ label, tone }) => ( + {task.tags.map((tag) => ( - {label} + {TAG_META[tag].label} ))}
- Assignee + {task.assignee?.fullName
- - {task.forks} - - - - {task.comments} - - + +
diff --git a/src/features/tasks/TasksView.tsx b/src/features/tasks/TasksView.tsx new file mode 100644 index 0000000..431eea1 --- /dev/null +++ b/src/features/tasks/TasksView.tsx @@ -0,0 +1,54 @@ +import { TaskBoard } from '@/features/tasks/TaskBoard' +import { groupTasksByStatus } from '@/features/tasks/task-display' +import { useTasks } from '@/features/tasks/useTasks' + +function TaskBoardSkeleton() { + return ( + <> +

+ Loading tasks… +

+