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
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"`.

Expand Down
2 changes: 1 addition & 1 deletion codegen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' } },
},
},
}
Expand Down
58 changes: 49 additions & 9 deletions src/app/router.test.tsx
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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 <article>')
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()
})

Expand All @@ -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()
Expand Down
5 changes: 2 additions & 3 deletions src/features/tasks/Dashboard.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="flex h-full flex-col gap-5 lg:gap-4">
<h1 className="sr-only">Dashboard</h1>
<Toolbar />
<TaskBoard columns={sampleColumns} />
<TasksView />
</div>
)
}
5 changes: 2 additions & 3 deletions src/features/tasks/MyTask.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="flex h-full flex-col gap-5 lg:gap-4">
<h1 className="sr-only">My Task</h1>
<Toolbar />
<TaskBoard columns={sampleColumns} />
<TasksView />
</div>
)
}
39 changes: 21 additions & 18 deletions src/features/tasks/TaskCard.tsx
Original file line number Diff line number Diff line change
@@ -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<TagTone, string> = {
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 (
<article className="flex flex-col gap-4 rounded-lg bg-neutral-4 p-4">
<div className="flex h-8 items-center gap-2">
Expand All @@ -19,38 +22,38 @@ export function TaskCard({ task }: { task: Task }) {
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-body-m font-semibold text-neutral-1">{task.points} Pts</span>
<span className="text-body-m font-semibold text-neutral-1">
{pointsLabel(task.pointEstimate)}
</span>
<span
className={`flex items-center gap-2 rounded px-4 py-1 text-body-m font-semibold ${
task.overdue ? 'bg-primary-4/10 text-primary-4' : 'bg-neutral-2/10 text-neutral-1'
due.overdue ? 'bg-primary-4/10 text-primary-4' : 'bg-neutral-2/10 text-neutral-1'
}`}
>
<AlarmIcon className="size-6" />
{task.dueLabel}
{due.label}
</span>
</div>
<div className="flex flex-wrap gap-2">
{task.tags.map(({ label, tone }) => (
{task.tags.map((tag) => (
<span
key={label}
className={`rounded px-4 py-1 text-body-m font-semibold whitespace-nowrap ${tagToneClasses[tone]}`}
key={tag}
className={`rounded px-4 py-1 text-body-m font-semibold whitespace-nowrap ${tagToneClasses[TAG_META[tag].tone]}`}
>
{label}
{TAG_META[tag].label}
</span>
))}
</div>
<div className="flex items-center justify-between">
<img className="size-8 rounded-full" src={avatarUrl} alt="Assignee" />
<img
className="size-8 rounded-full"
src={task.assignee?.avatar ?? avatarUrl}
alt={task.assignee?.fullName ?? 'Unassigned'}
/>
<div className="flex items-center gap-4 text-neutral-1">
<AttachIcon className="size-4" />
<span className="flex items-center gap-1 text-body-m">
{task.forks}
<ForkIcon className="size-4" />
</span>
<span className="flex items-center gap-1 text-body-m">
{task.comments}
<ChatIcon className="size-4" />
</span>
<ForkIcon className="size-4" />
<ChatIcon className="size-4" />
</div>
</div>
</article>
Expand Down
54 changes: 54 additions & 0 deletions src/features/tasks/TasksView.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<>
<p role="status" className="sr-only">
Loading tasks…
</p>
<div aria-hidden="true" className="flex min-h-0 flex-1 gap-4 overflow-x-auto lg:gap-8">
{Array.from({ length: 5 }, (_, column) => (
<div key={column} className="flex w-85 shrink-0 flex-col gap-4 lg:w-87">
<div className="h-8 w-40 animate-pulse rounded bg-neutral-4" />
<div className="h-44 animate-pulse rounded-lg bg-neutral-4" />
<div className="h-44 animate-pulse rounded-lg bg-neutral-4" />
</div>
))}
</div>
</>
)
}

export function TasksView() {
const { data, isPending, isError, refetch } = useTasks()

if (isPending) return <TaskBoardSkeleton />

if (isError) {
return (
<div
role="alert"
className="flex flex-col items-start gap-4 rounded-lg bg-primary-4/10 p-4 text-body-m text-primary-4"
>
<p>Something went wrong while loading tasks.</p>
<button
type="button"
onClick={() => {
void refetch()
}}
className="rounded bg-primary-4 px-4 py-1 font-semibold text-neutral-1"
>
Try again
</button>
</div>
)
}

if (data.tasks.length === 0) {
return <p className="p-4 text-body-m text-neutral-2">No tasks found.</p>
}

return <TaskBoard columns={groupTasksByStatus(data.tasks)} />
}
20 changes: 20 additions & 0 deletions src/features/tasks/queries.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { graphql } from '@/graphql/generated'

export const TasksDocument = graphql(`
query Tasks($input: FilterTaskInput!) {
tasks(input: $input) {
id
name
dueDate
pointEstimate
position
status
tags
assignee {
id
fullName
avatar
}
}
}
`)
Loading