Skip to content
Draft
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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
node_modules/
dist/
28 changes: 23 additions & 5 deletions server.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,33 @@
import express, { Request, Response } from 'express';
import { execSync } from 'child_process';
import { CreateTaskBody } from './src/models/task';
import { getAllTasks, createTask, validPriorities } from './src/services/taskService';
import { CreateTaskBody, Priority, Status } from './src/models/task';
import { getFilteredTasks, createTask, validPriorities } from './src/services/taskService';

const validStatuses: Status[] = ['pending', 'completed'];

// --- App setup ---
const app = express();
app.use(express.json());

// GET /tasks — return all tasks created during the session
app.get('/tasks', (_req: Request, res: Response) => {
const tasks = getAllTasks();
// GET /tasks — return tasks, optionally filtered by ?status= and/or ?priority=
app.get('/tasks', (req: Request, res: Response) => {
const { status, priority } = req.query;

if (status !== undefined && !validStatuses.includes(status as Status)) {
res.status(400).json({ error: `status must be one of: ${validStatuses.join(', ')}.` });
return;
}

if (priority !== undefined && !validPriorities.includes(priority as Priority)) {
res.status(400).json({ error: `priority must be one of: ${validPriorities.join(', ')}.` });
return;
}

const tasks = getFilteredTasks({
status: status as Status | undefined,
priority: priority as Priority | undefined,
});

res.status(200).json({
count: tasks.length,
tasks,
Expand Down
11 changes: 7 additions & 4 deletions src/hooks/useTasks.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import { useEffect, useState } from 'react';
import { Task } from '../models/task';
import { fetchTasks } from '../services/taskApiService';
import { fetchTasks, TaskFilterParams } from '../services/taskApiService';

interface UseTasksResult {
tasks: Task[];
loading: boolean;
error: string | null;
filters: TaskFilterParams;
setFilters: (filters: TaskFilterParams) => void;
reload: () => void;
}

Expand All @@ -14,13 +16,14 @@ export function useTasks(): UseTasksResult {
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
const [refreshKey, setRefreshKey] = useState(0);
const [filters, setFilters] = useState<TaskFilterParams>({});

useEffect(() => {
let cancelled = false;
setLoading(true);
setError(null);

fetchTasks()
fetchTasks(filters)
.then((data) => {
if (!cancelled) setTasks(data);
})
Expand All @@ -34,9 +37,9 @@ export function useTasks(): UseTasksResult {
return () => {
cancelled = true;
};
}, [refreshKey]);
}, [refreshKey, filters]);

const reload = () => setRefreshKey((k) => k + 1);

return { tasks, loading, error, reload };
return { tasks, loading, error, filters, setFilters, reload };
}
35 changes: 34 additions & 1 deletion src/pages/TasksPage.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,18 @@
import { useTasks } from '../hooks/useTasks';
import { TaskList } from '../components/TaskList';
import { Priority, Status } from '../models/task';
import '../styles/tasks.css';

export function TasksPage() {
const { tasks, loading, error, reload } = useTasks();
const { tasks, loading, error, filters, setFilters, reload } = useTasks();

function handleStatusChange(e: React.ChangeEvent<HTMLSelectElement>) {
setFilters({ ...filters, status: e.target.value ? (e.target.value as Status) : undefined });
}

function handlePriorityChange(e: React.ChangeEvent<HTMLSelectElement>) {
setFilters({ ...filters, priority: e.target.value ? (e.target.value as Priority) : undefined });
}

return (
<main className="tasks-page">
Expand All @@ -13,7 +22,31 @@ export function TasksPage() {
{loading ? 'Refreshing…' : 'Refresh'}
</button>
</header>
<div className="tasks-page__filters">
<select
className="filter-select"
value={filters.status ?? ''}
onChange={handleStatusChange}
aria-label="Filter by status"
>
<option value="">All Statuses</option>
<option value="pending">Pending</option>
<option value="completed">Completed</option>
</select>
<select
className="filter-select"
value={filters.priority ?? ''}
onChange={handlePriorityChange}
aria-label="Filter by priority"
>
<option value="">All Priorities</option>
<option value="Low">Low</option>
<option value="Medium">Medium</option>
<option value="High">High</option>
</select>
</div>
<TaskList tasks={tasks} loading={loading} error={error} onReload={reload} />
</main>
);
}

17 changes: 14 additions & 3 deletions src/services/taskApiService.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Task } from '../models/task';
import { Task, Priority, Status } from '../models/task';

const API_BASE = '/tasks';

Expand All @@ -7,8 +7,19 @@ export interface TasksResponse {
tasks: Task[];
}

export async function fetchTasks(): Promise<Task[]> {
const response = await fetch(API_BASE);
export interface TaskFilterParams {
status?: Status;
priority?: Priority;
}

export async function fetchTasks(filters?: TaskFilterParams): Promise<Task[]> {
const params = new URLSearchParams();
if (filters?.status) params.set('status', filters.status);
if (filters?.priority) params.set('priority', filters.priority);

const url = params.toString() ? `${API_BASE}?${params.toString()}` : API_BASE;

const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to fetch tasks: ${response.statusText}`);
}
Expand Down
35 changes: 34 additions & 1 deletion src/services/taskService.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { v4 as uuidv4 } from 'uuid';
import { Task, CreateTaskBody, Priority } from '../models/task';
import { Task, CreateTaskBody, Priority, Status } from '../models/task';
import { seedTasks } from './taskSeed';

/** In-memory task store. Pre-loaded with seed data on startup. */
Expand All @@ -8,6 +8,12 @@ const tasks: Task[] = [...seedTasks];
/** Allowed priority values for a task. */
export const validPriorities: Priority[] = ['Low', 'Medium', 'High'];

/** Optional filters for querying tasks. */
export interface TaskFilters {
status?: Status;
priority?: Priority;
}

/**
* Returns all tasks currently held in memory.
*
Expand All @@ -21,6 +27,33 @@ export function getAllTasks(): Task[] {
return tasks;
}

/**
* Returns tasks filtered by optional status and/or priority.
* Omitting a filter means that dimension is not restricted.
*
* @param {TaskFilters} filters - Optional status and/or priority filter values.
* @returns {Task[]} Array of tasks matching all provided filters.
*
* @example
* // GET /tasks?status=pending
* const pending = getFilteredTasks({ status: 'pending' });
*
* @example
* // GET /tasks?priority=High
* const highPriority = getFilteredTasks({ priority: 'High' });
*
* @example
* // GET /tasks?status=pending&priority=High
* const urgent = getFilteredTasks({ status: 'pending', priority: 'High' });
*/
export function getFilteredTasks(filters: TaskFilters): Task[] {
return tasks.filter((task) => {
if (filters.status !== undefined && task.status !== filters.status) return false;
if (filters.priority !== undefined && task.priority !== filters.priority) return false;
return true;
});
}

/**
* Creates a new task and appends it to the in-memory store.
*
Expand Down
25 changes: 24 additions & 1 deletion src/styles/tasks.css
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,37 @@
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1.5rem;
margin-bottom: 1rem;
}

.tasks-page__header h1 {
font-size: 1.5rem;
margin: 0;
}

/* ── Filters ────────────────────────────────── */
.tasks-page__filters {
display: flex;
gap: 0.75rem;
margin-bottom: 1.25rem;
flex-wrap: wrap;
}

.filter-select {
padding: 0.4rem 0.75rem;
border: 1px solid #d1d5db;
border-radius: 6px;
background: #fff;
font-size: 0.875rem;
cursor: pointer;
color: #374151;
}

.filter-select:focus {
outline: 2px solid #6366f1;
outline-offset: 1px;
}

/* ── Button ─────────────────────────────────── */
.btn {
padding: 0.4rem 1rem;
Expand Down