diff --git a/.npmrc b/.npmrc index b6f27f1..d13caea 100644 --- a/.npmrc +++ b/.npmrc @@ -1 +1,2 @@ engine-strict=true +min-release-age=7 diff --git a/README.md b/README.md index 1069ff3..621889f 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ _Live app and video walkthrough coming soon._ ## Setup & Running Locally -Requires Node 24 (see `.nvmrc`). +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 @@ -103,7 +103,7 @@ src/ ## What's Implemented - [x] Initial setup (folder structure, routing, styles solution, linting/formatting, error boundary, tests, CI) -- [ ] Dashboard UI (static) +- [x] Dashboard UI (static): sidebar with mobile drawer, header, toolbar, five status columns, task cards - [ ] API connection — fetch tasks, loading/error/empty states - [ ] Create task - [ ] Update task diff --git a/package.json b/package.json index 93e7504..af815cb 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,8 @@ "version": "0.0.0", "type": "module", "engines": { - "node": ">=24" + "node": ">=24.14.1", + "npm": ">=11.10.0" }, "scripts": { "dev": "vite", diff --git a/src/app/router.test.tsx b/src/app/router.test.tsx index 5dda587..55f9d8f 100644 --- a/src/app/router.test.tsx +++ b/src/app/router.test.tsx @@ -22,22 +22,22 @@ function renderAt(path: string) { describe('app routes', () => { it('renders the dashboard at /', () => { renderAt('/') - expect(screen.getByRole('heading', { name: /dashboard/i })).toBeInTheDocument() + expect(screen.getByRole('heading', { level: 1, name: /dashboard/i })).toBeInTheDocument() }) it('renders the settings page at /settings', () => { renderAt('/settings') - expect(screen.getByRole('heading', { name: /settings/i })).toBeInTheDocument() + expect(screen.getByRole('heading', { level: 1, name: /settings/i })).toBeInTheDocument() }) it('renders the not-found page for unknown paths', () => { renderAt('/does-not-exist') - expect(screen.getByRole('heading', { name: /not found/i })).toBeInTheDocument() + expect(screen.getByRole('heading', { level: 1, name: /not found/i })).toBeInTheDocument() }) it('renders the my-task placeholder page at /my-task', () => { renderAt('/my-task') - expect(screen.getByRole('heading', { name: /my task/i })).toBeInTheDocument() + expect(screen.getByRole('heading', { level: 1, name: /my task/i })).toBeInTheDocument() }) }) @@ -56,13 +56,53 @@ describe('sidebar navigation', () => { it('marks nothing active on unknown deep paths (renders not-found)', () => { renderAt('/my-task/anything') - expect(screen.getByRole('heading', { name: /not found/i })).toBeInTheDocument() + expect(screen.getByRole('heading', { level: 1, name: /not found/i })).toBeInTheDocument() const nav = within(screen.getByRole('navigation')) expect(nav.getByRole('link', { name: /my task/i })).not.toHaveAttribute('aria-current') expect(nav.getByRole('link', { name: /dashboard/i })).not.toHaveAttribute('aria-current') }) }) +describe('dashboard main content', () => { + it('renders the five required status columns', () => { + renderAt('/') + for (const title of ['Backlog', 'To Do', 'In Progress', 'Done', 'Cancelled']) { + expect( + screen.getByRole('heading', { level: 2, name: new RegExp(title, 'i') }), + ).toBeInTheDocument() + } + }) + + it('renders a task card with its required fields', () => { + renderAt('/') + const card = screen.getByRole('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(/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.getByRole('img', { name: /task options/i })).toBeInTheDocument() + }) + + it('renders the toolbar view icons and the add-task affordance', () => { + renderAt('/') + expect(screen.getByRole('img', { name: /grid view/i })).toBeInTheDocument() + expect(screen.getByRole('img', { name: /list view/i })).toBeInTheDocument() + expect(screen.getAllByRole('img', { name: /add task/i }).length).toBeGreaterThan(0) + }) + + it('marks the correct mobile tab active per route', () => { + renderAt('/my-task') + const taskTab = screen.getByText('Task') + const tabs = taskTab.parentElement?.parentElement + if (!tabs) throw new Error('expected the mobile tabs container to exist') + expect(taskTab).toHaveClass('text-primary-4') + expect(within(tabs).getByText('Dashboard')).toHaveClass('text-neutral-2') + }) +}) + describe('header', () => { it('renders a controlled search input', async () => { const user = userEvent.setup() @@ -102,7 +142,7 @@ describe('mobile navigation drawer', () => { await user.click(screen.getByRole('button', { name: /open navigation/i })) const nav = within(screen.getByRole('navigation')) await user.click(nav.getByRole('link', { name: /my task/i })) - expect(screen.getByRole('heading', { name: /my task/i })).toBeInTheDocument() + expect(screen.getByRole('heading', { level: 1, name: /my task/i })).toBeInTheDocument() expect(screen.getByRole('button', { name: /open navigation/i })).toHaveAttribute( 'aria-expanded', 'false', diff --git a/src/app/router.tsx b/src/app/router.tsx index 22e5df6..e21c77c 100644 --- a/src/app/router.tsx +++ b/src/app/router.tsx @@ -6,7 +6,6 @@ import { Settings } from '@/features/settings/Settings' import { NotFound } from '@/app/NotFound' import { RouteError } from '@/app/RouteError' -// Exported separately so tests can mount the same tree with createMemoryRouter. export const routes: RouteObject[] = [ { path: '/', diff --git a/src/components/layout/Header.tsx b/src/components/layout/Header.tsx index 9ff1b10..16eb692 100644 --- a/src/components/layout/Header.tsx +++ b/src/components/layout/Header.tsx @@ -1,33 +1,6 @@ import { useState } from 'react' import avatarUrl from '@/assets/avatar.png' - -interface IconProps { - className?: string -} - -function SearchIcon(props: IconProps) { - return ( - - ) -} - -function BellIcon(props: IconProps) { - return ( - - ) -} +import { BellIcon, SearchIcon } from '@/components/ui/icons' interface HeaderProps { sidebarOpen: boolean diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index da0853a..871e5cb 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -1,32 +1,10 @@ import { NavLink } from 'react-router' import logoUrl from '@/assets/logos/ravn-logomark-white.svg' - -interface IconProps { - className?: string -} - -function DashboardIcon(props: IconProps) { - return ( - - ) -} - -function MyTaskIcon(props: IconProps) { - return ( - - ) -} +import { GridIcon, ListIcon } from '@/components/ui/icons' const navItems = [ - { to: '/', label: 'Dashboard', Icon: DashboardIcon }, - { to: '/my-task', label: 'My Task', Icon: MyTaskIcon }, + { to: '/', label: 'Dashboard', Icon: GridIcon }, + { to: '/my-task', label: 'My Task', Icon: ListIcon }, ] interface SidebarProps { diff --git a/src/components/ui/icons.tsx b/src/components/ui/icons.tsx new file mode 100644 index 0000000..dba5173 --- /dev/null +++ b/src/components/ui/icons.tsx @@ -0,0 +1,46 @@ +export interface IconProps { + className?: string +} + +export function GridIcon(props: IconProps) { + return ( + + ) +} + +export function ListIcon(props: IconProps) { + return ( + + ) +} + +export function SearchIcon(props: IconProps) { + return ( + + ) +} + +export function BellIcon(props: IconProps) { + return ( + + ) +} diff --git a/src/features/tasks/Dashboard.tsx b/src/features/tasks/Dashboard.tsx index 60078d6..21168eb 100644 --- a/src/features/tasks/Dashboard.tsx +++ b/src/features/tasks/Dashboard.tsx @@ -1,3 +1,13 @@ +import { Toolbar } from '@/features/tasks/Toolbar' +import { TaskBoard } from '@/features/tasks/TaskBoard' +import { sampleColumns } from '@/features/tasks/sample-tasks' + export function Dashboard() { - return

Dashboard

+ return ( +
+

Dashboard

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

My Task

+ return ( +
+

My Task

+ + +
+ ) } diff --git a/src/features/tasks/TaskBoard.tsx b/src/features/tasks/TaskBoard.tsx new file mode 100644 index 0000000..7cb96df --- /dev/null +++ b/src/features/tasks/TaskBoard.tsx @@ -0,0 +1,27 @@ +import { TaskCard } from '@/features/tasks/TaskCard' +import type { BoardColumn } from '@/features/tasks/types' + +export function TaskBoard({ columns }: { columns: BoardColumn[] }) { + return ( +
+ {columns.map(({ title, tasks }) => ( +
+

+ {title} ({String(tasks.length).padStart(2, '0')}) +

+
    + {tasks.map((task) => ( +
  • + +
  • + ))} +
+
+ ))} +
+ ) +} diff --git a/src/features/tasks/TaskCard.tsx b/src/features/tasks/TaskCard.tsx new file mode 100644 index 0000000..e271353 --- /dev/null +++ b/src/features/tasks/TaskCard.tsx @@ -0,0 +1,58 @@ +import avatarUrl from '@/assets/avatar.png' +import { AlarmIcon, AttachIcon, ChatIcon, DotsIcon, ForkIcon } from '@/features/tasks/icons' +import type { Task, TagTone } from '@/features/tasks/types' + +const tagToneClasses: Record = { + secondary: 'bg-secondary-4/10 text-secondary-4', + tertiary: 'bg-tertiary-4/10 text-tertiary-4', +} + +export function TaskCard({ task }: { task: Task }) { + return ( +
+
+

+ {task.name} +

+ + + +
+
+ {task.points} Pts + + + {task.dueLabel} + +
+
+ {task.tags.map(({ label, tone }) => ( + + {label} + + ))} +
+
+ Assignee +
+ + + {task.forks} + + + + {task.comments} + + +
+
+
+ ) +} diff --git a/src/features/tasks/Toolbar.tsx b/src/features/tasks/Toolbar.tsx new file mode 100644 index 0000000..22edf26 --- /dev/null +++ b/src/features/tasks/Toolbar.tsx @@ -0,0 +1,62 @@ +import { useLocation } from 'react-router' +import { GridIcon, ListIcon } from '@/components/ui/icons' +import { PlusIcon } from '@/features/tasks/icons' + +export function Toolbar() { + const { pathname } = useLocation() + const onMyTask = pathname === '/my-task' + return ( +
+
+
+ + Dashboard + + +
+
+ + Task + + +
+
+
+
+ + + + + + +
+ + + +
+ + + +
+ ) +} diff --git a/src/features/tasks/icons.tsx b/src/features/tasks/icons.tsx new file mode 100644 index 0000000..309be83 --- /dev/null +++ b/src/features/tasks/icons.tsx @@ -0,0 +1,69 @@ +import type { IconProps } from '@/components/ui/icons' + +export function PlusIcon(props: IconProps) { + return ( + + ) +} + +export function DotsIcon(props: IconProps) { + return ( + + ) +} + +export function AlarmIcon(props: IconProps) { + return ( + + ) +} + +export function AttachIcon(props: IconProps) { + return ( + + ) +} + +export function ForkIcon(props: IconProps) { + return ( + + ) +} + +export function ChatIcon(props: IconProps) { + return ( + + ) +} diff --git a/src/features/tasks/sample-tasks.ts b/src/features/tasks/sample-tasks.ts new file mode 100644 index 0000000..0b19a20 --- /dev/null +++ b/src/features/tasks/sample-tasks.ts @@ -0,0 +1,108 @@ +import type { BoardColumn } from '@/features/tasks/types' + +const appTags = [ + { label: 'IOS APP', tone: 'secondary' as const }, + { label: 'ANDROID', tone: 'tertiary' as const }, +] + +export const sampleColumns: BoardColumn[] = [ + { + title: 'Backlog', + tasks: [ + { + id: 'backlog-1', + name: 'Slack', + points: 3, + dueLabel: 'TODAY', + forks: 5, + comments: 3, + tags: appTags, + }, + { + id: 'backlog-2', + name: 'Google', + points: 3, + dueLabel: '6 JULY, 2020', + forks: 5, + comments: 3, + tags: appTags, + }, + ], + }, + { + title: 'To Do', + tasks: [ + { + id: 'todo-1', + name: 'Twitter', + points: 3, + dueLabel: 'YESTERDAY', + overdue: true, + forks: 5, + comments: 3, + tags: appTags, + }, + { + id: 'todo-2', + name: 'Maxxis Tyres', + points: 3, + dueLabel: '6 JULY, 2020', + forks: 5, + comments: 3, + tags: appTags, + }, + ], + }, + { + title: 'In Progress', + tasks: [ + { + id: 'progress-1', + name: 'Samsung', + points: 3, + dueLabel: '6 JULY, 2020', + forks: 5, + comments: 3, + tags: appTags, + }, + { + id: 'progress-2', + name: 'Tesla', + points: 3, + dueLabel: 'YESTERDAY', + overdue: true, + forks: 5, + comments: 3, + tags: appTags, + }, + ], + }, + { + title: 'Done', + tasks: [ + { + id: 'done-1', + name: 'Dashboard Design', + points: 3, + dueLabel: 'TODAY', + forks: 5, + comments: 3, + tags: appTags, + }, + ], + }, + { + title: 'Cancelled', + tasks: [ + { + id: 'cancelled-1', + name: 'Micromax Logo Design', + points: 3, + dueLabel: '6 JULY, 2020', + forks: 5, + comments: 3, + tags: appTags, + }, + ], + }, +] diff --git a/src/features/tasks/types.ts b/src/features/tasks/types.ts new file mode 100644 index 0000000..83b0672 --- /dev/null +++ b/src/features/tasks/types.ts @@ -0,0 +1,17 @@ +export type TagTone = 'secondary' | 'tertiary' + +export interface Task { + id: string + name: string + points: number + dueLabel: string + overdue?: boolean + forks: number + comments: number + tags: { label: string; tone: TagTone }[] +} + +export interface BoardColumn { + title: string + tasks: Task[] +}