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
1 change: 1 addition & 0 deletions .npmrc
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
engine-strict=true
min-release-age=7
Comment thread
coderabbitai[bot] marked this conversation as resolved.
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@
"version": "0.0.0",
"type": "module",
"engines": {
"node": ">=24"
"node": ">=24.14.1",
"npm": ">=11.10.0"
},
"scripts": {
"dev": "vite",
Expand Down
52 changes: 46 additions & 6 deletions src/app/router.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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()
})
})

Expand All @@ -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 <article>')
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()
Expand Down Expand Up @@ -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',
Expand Down
1 change: 0 additions & 1 deletion src/app/router.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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: '/',
Expand Down
29 changes: 1 addition & 28 deletions src/components/layout/Header.tsx
Original file line number Diff line number Diff line change
@@ -1,33 +1,6 @@
import { useState } from 'react'
import avatarUrl from '@/assets/avatar.png'

interface IconProps {
className?: string
}

function SearchIcon(props: IconProps) {
return (
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true" {...props}>
<path
transform="translate(2 2)"
d="M16.031 14.617L20.314 18.899L18.899 20.314L14.617 16.031C13.0237 17.3082 11.042 18.0029 9 18C4.032 18 0 13.968 0 9C0 4.032 4.032 0 9 0C13.968 0 18 4.032 18 9C18.0029 11.042 17.3082 13.0237 16.031 14.617ZM14.025 13.875C15.2941 12.5699 16.0029 10.8204 16 9C16 5.132 12.867 2 9 2C5.132 2 2 5.132 2 9C2 12.867 5.132 16 9 16C10.8204 16.0029 12.5699 15.2941 13.875 14.025L14.025 13.875Z"
fill="currentColor"
/>
</svg>
)
}

function BellIcon(props: IconProps) {
return (
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true" {...props}>
<path
transform="translate(2 2)"
d="M18 15H20V17H0V15H2V8C2 5.87827 2.84285 3.84344 4.34315 2.34315C5.84344 0.842855 7.87827 0 10 0C12.1217 0 14.1566 0.842855 15.6569 2.34315C17.1571 3.84344 18 5.87827 18 8V15ZM16 15V8C16 6.4087 15.3679 4.88258 14.2426 3.75736C13.1174 2.63214 11.5913 2 10 2C8.4087 2 6.88258 2.63214 5.75736 3.75736C4.63214 4.88258 4 6.4087 4 8V15H16ZM7 19H13V21H7V19Z"
fill="currentColor"
/>
</svg>
)
}
import { BellIcon, SearchIcon } from '@/components/ui/icons'

interface HeaderProps {
sidebarOpen: boolean
Expand Down
28 changes: 3 additions & 25 deletions src/components/layout/Sidebar.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true" {...props}>
<path
d="M3 3H11V11H3V3ZM3 13H11V21H3V13ZM13 3H21V11H13V3ZM13 13H21V21H13V13ZM15 5V9H19V5H15ZM15 15V19H19V15H15ZM5 5V9H9V5H5ZM5 15V19H9V15H5Z"
fill="currentColor"
/>
</svg>
)
}

function MyTaskIcon(props: IconProps) {
return (
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true" {...props}>
<path d="M3 4H21V6H3V4ZM3 11H21V13H3V11ZM3 18H21V20H3V18Z" fill="currentColor" />
</svg>
)
}
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 {
Expand Down
46 changes: 46 additions & 0 deletions src/components/ui/icons.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
export interface IconProps {
className?: string
}

export function GridIcon(props: IconProps) {
return (
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true" {...props}>
<path
d="M3 3H11V11H3V3ZM3 13H11V21H3V13ZM13 3H21V11H13V3ZM13 13H21V21H13V13ZM15 5V9H19V5H15ZM15 15V19H19V15H15ZM5 5V9H9V5H5ZM5 15V19H9V15H5Z"
fill="currentColor"
/>
</svg>
)
}

export function ListIcon(props: IconProps) {
return (
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true" {...props}>
<path d="M3 4H21V6H3V4ZM3 11H21V13H3V11ZM3 18H21V20H3V18Z" fill="currentColor" />
</svg>
)
}

export function SearchIcon(props: IconProps) {
return (
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true" {...props}>
<path
transform="translate(2 2)"
d="M16.031 14.617L20.314 18.899L18.899 20.314L14.617 16.031C13.0237 17.3082 11.042 18.0029 9 18C4.032 18 0 13.968 0 9C0 4.032 4.032 0 9 0C13.968 0 18 4.032 18 9C18.0029 11.042 17.3082 13.0237 16.031 14.617ZM14.025 13.875C15.2941 12.5699 16.0029 10.8204 16 9C16 5.132 12.867 2 9 2C5.132 2 2 5.132 2 9C2 12.867 5.132 16 9 16C10.8204 16.0029 12.5699 15.2941 13.875 14.025L14.025 13.875Z"
fill="currentColor"
/>
</svg>
)
}

export function BellIcon(props: IconProps) {
return (
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true" {...props}>
<path
transform="translate(2 2)"
d="M18 15H20V17H0V15H2V8C2 5.87827 2.84285 3.84344 4.34315 2.34315C5.84344 0.842855 7.87827 0 10 0C12.1217 0 14.1566 0.842855 15.6569 2.34315C17.1571 3.84344 18 5.87827 18 8V15ZM16 15V8C16 6.4087 15.3679 4.88258 14.2426 3.75736C13.1174 2.63214 11.5913 2 10 2C8.4087 2 6.88258 2.63214 5.75736 3.75736C4.63214 4.88258 4 6.4087 4 8V15H16ZM7 19H13V21H7V19Z"
fill="currentColor"
/>
</svg>
)
}
12 changes: 11 additions & 1 deletion src/features/tasks/Dashboard.tsx
Original file line number Diff line number Diff line change
@@ -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 <h1>Dashboard</h1>
return (
<div className="flex h-full flex-col gap-5 lg:gap-4">
<h1 className="sr-only">Dashboard</h1>
<Toolbar />
<TaskBoard columns={sampleColumns} />
</div>
)
}
12 changes: 11 additions & 1 deletion src/features/tasks/MyTask.tsx
Original file line number Diff line number Diff line change
@@ -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 <h1>My Task</h1>
return (
<div className="flex h-full flex-col gap-5 lg:gap-4">
<h1 className="sr-only">My Task</h1>
<Toolbar />
<TaskBoard columns={sampleColumns} />
</div>
)
}
27 changes: 27 additions & 0 deletions src/features/tasks/TaskBoard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { TaskCard } from '@/features/tasks/TaskCard'
import type { BoardColumn } from '@/features/tasks/types'

export function TaskBoard({ columns }: { columns: BoardColumn[] }) {
return (
<div className="flex min-h-0 flex-1 gap-4 overflow-x-auto lg:gap-8">
{columns.map(({ title, tasks }) => (
<section
key={title}
aria-label={title}
className="flex w-85 shrink-0 flex-col gap-4 lg:w-87"
>
<h2 className="text-body-l font-semibold text-neutral-1">
{title} ({String(tasks.length).padStart(2, '0')})
</h2>
<ul className="flex min-h-0 flex-col gap-4 overflow-y-auto">
{tasks.map((task) => (
<li key={task.id}>
<TaskCard task={task} />
</li>
))}
</ul>
</section>
))}
</div>
)
}
58 changes: 58 additions & 0 deletions src/features/tasks/TaskCard.tsx
Original file line number Diff line number Diff line change
@@ -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<TagTone, string> = {
secondary: 'bg-secondary-4/10 text-secondary-4',
tertiary: 'bg-tertiary-4/10 text-tertiary-4',
}

export function TaskCard({ task }: { task: Task }) {
return (
<article className="flex 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}
</h3>
<span role="img" aria-label="Task options" className="shrink-0">
<DotsIcon className="size-6 text-neutral-1" />
</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={`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'
}`}
>
<AlarmIcon className="size-6" />
{task.dueLabel}
</span>
</div>
<div className="flex flex-wrap gap-2">
{task.tags.map(({ label, tone }) => (
<span
key={label}
className={`rounded px-4 py-1 text-body-m font-semibold whitespace-nowrap ${tagToneClasses[tone]}`}
>
{label}
</span>
))}
</div>
<div className="flex items-center justify-between">
<img className="size-8 rounded-full" src={avatarUrl} alt="Assignee" />
<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>
</div>
</div>
</article>
)
}
Loading