Create a frontend wireframe. (#7)

Sets up API methods and types.
Sets up a colorscheme.
Sets up a homepage.

Removes tailwind in favor of mui for now.

Reviewed-on: #7
Co-authored-by: Drew Galbraith <drew@tiramisu.one>
Co-committed-by: Drew Galbraith <drew@tiramisu.one>
This commit is contained in:
Drew 2025-09-23 04:08:45 +00:00 committed by Drew
parent 7d2b7fc90c
commit d60d834f38
27 changed files with 3114 additions and 977 deletions

View file

@ -0,0 +1,267 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { apiClient } from './api'
import type { Task, CreateTaskRequest, UpdateTaskRequest } from '~/types/task'
import { TaskStatus } from '~/types/task'
// Mock fetch globally
const mockFetch = vi.fn()
global.fetch = mockFetch
// Sample task data for testing
const mockTask: Task = {
id: '550e8400-e29b-41d4-a716-446655440000',
title: 'Test Task',
description: 'Test Description',
status: TaskStatus.Todo,
created_at: '2023-01-01T00:00:00Z',
updated_at: '2023-01-01T00:00:00Z',
completed_at: null,
}
const mockTasks: Task[] = [
mockTask,
{
...mockTask,
id: '550e8400-e29b-41d4-a716-446655440001',
title: 'Another Task',
status: TaskStatus.Done,
completed_at: '2023-01-01T01:00:00Z',
},
]
describe('API Client', () => {
beforeEach(() => {
mockFetch.mockClear()
// Silence console.log during tests
vi.spyOn(console, 'log').mockImplementation(() => {})
vi.spyOn(console, 'error').mockImplementation(() => {})
})
afterEach(() => {
vi.restoreAllMocks()
})
describe('listTasks', () => {
it('should fetch all tasks successfully', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
statusText: 'OK',
json: () => Promise.resolve(mockTasks),
})
const result = await apiClient.listTasks()
expect(mockFetch).toHaveBeenCalledWith('/api/tasks', {
headers: { 'Content-Type': 'application/json' },
})
expect(result).toEqual(mockTasks)
})
it('should handle empty task list', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
statusText: 'OK',
json: () => Promise.resolve([]),
})
const result = await apiClient.listTasks()
expect(result).toEqual([])
})
it('should throw error on failed request', async () => {
mockFetch.mockResolvedValueOnce({
ok: false,
status: 500,
statusText: 'Internal Server Error',
json: () => Promise.resolve({ message: 'Server error' }),
})
await expect(apiClient.listTasks()).rejects.toThrow('Server error')
})
})
describe('getTask', () => {
it('should fetch single task successfully', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
statusText: 'OK',
json: () => Promise.resolve(mockTask),
})
const result = await apiClient.getTask(mockTask.id)
expect(mockFetch).toHaveBeenCalledWith(`/api/tasks/${mockTask.id}`, {
headers: { 'Content-Type': 'application/json' },
})
expect(result).toEqual(mockTask)
})
it('should handle task not found', async () => {
mockFetch.mockResolvedValueOnce({
ok: false,
status: 404,
statusText: 'Not Found',
json: () => Promise.resolve({ message: 'Task not found' }),
})
await expect(apiClient.getTask('nonexistent-id')).rejects.toThrow(
'Task not found'
)
})
})
describe('createTask', () => {
it('should create task successfully', async () => {
const newTaskData: CreateTaskRequest = {
title: 'New Task',
description: 'New Description',
}
mockFetch.mockResolvedValueOnce({
ok: true,
status: 201,
statusText: 'Created',
json: () => Promise.resolve(mockTask),
})
const result = await apiClient.createTask(newTaskData)
expect(mockFetch).toHaveBeenCalledWith('/api/tasks', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(newTaskData),
})
expect(result).toEqual(mockTask)
})
it('should handle validation errors', async () => {
const invalidData: CreateTaskRequest = {
title: '',
}
mockFetch.mockResolvedValueOnce({
ok: false,
status: 422,
statusText: 'Unprocessable Entity',
json: () => Promise.resolve({ message: 'Title must not be empty' }),
})
await expect(apiClient.createTask(invalidData)).rejects.toThrow(
'Title must not be empty'
)
})
})
describe('updateTask', () => {
it('should update task successfully', async () => {
const updateData: UpdateTaskRequest = {
title: 'Updated Task',
status: TaskStatus.Done,
}
const updatedTask = { ...mockTask, ...updateData }
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
statusText: 'OK',
json: () => Promise.resolve(updatedTask),
})
const result = await apiClient.updateTask(mockTask.id, updateData)
expect(mockFetch).toHaveBeenCalledWith(`/api/tasks/${mockTask.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updateData),
})
expect(result).toEqual(updatedTask)
})
it('should handle partial updates', async () => {
const updateData: UpdateTaskRequest = {
status: TaskStatus.Done,
}
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
statusText: 'OK',
json: () => Promise.resolve({ ...mockTask, status: TaskStatus.Done }),
})
const result = await apiClient.updateTask(mockTask.id, updateData)
expect(result.status).toBe(TaskStatus.Done)
})
})
describe('deleteTask', () => {
it('should delete task successfully', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 204,
statusText: 'No Content',
})
const result = await apiClient.deleteTask(mockTask.id)
expect(result).toBeNull()
expect(mockFetch).toHaveBeenCalledWith(`/api/tasks/${mockTask.id}`, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
})
})
it('should handle delete errors', async () => {
mockFetch.mockResolvedValueOnce({
ok: false,
status: 404,
statusText: 'Not Found',
json: () => Promise.resolve({ message: 'Task not found' }),
})
await expect(apiClient.deleteTask('nonexistent-id')).rejects.toThrow(
'Task not found'
)
})
})
describe('Error Handling', () => {
it('should handle network errors', async () => {
mockFetch.mockRejectedValueOnce(new Error('Network error'))
await expect(apiClient.listTasks()).rejects.toThrow('Network error')
})
it('should handle malformed JSON responses', async () => {
mockFetch.mockResolvedValueOnce({
ok: false,
status: 500,
statusText: 'Internal Server Error',
json: () => Promise.reject(new Error('Invalid JSON')),
})
await expect(apiClient.listTasks()).rejects.toThrow(
'HTTP 500: Internal Server Error'
)
})
it('should handle responses with no error message', async () => {
mockFetch.mockResolvedValueOnce({
ok: false,
status: 400,
statusText: 'Bad Request',
json: () => Promise.resolve({}),
})
await expect(apiClient.listTasks()).rejects.toThrow(
'HTTP 400: Bad Request'
)
})
})
})

View file

@ -0,0 +1,113 @@
import type {
Task,
CreateTaskRequest,
UpdateTaskRequest,
ApiError,
} from '~/types/task'
const API_BASE_URL = '/api'
class ApiClient {
private async fetchWrapper<T>(
endpoint: string,
options: RequestInit = {}
): Promise<T> {
const url = `${API_BASE_URL}${endpoint}`
const config: RequestInit = {
headers: {
'Content-Type': 'application/json',
...options.headers,
},
...options,
}
if (process.env.NODE_ENV === 'development') {
console.log(`API Request: ${config.method || 'GET'} ${url}`, {
body: config.body,
headers: config.headers,
})
}
try {
const response = await fetch(url, config)
if (process.env.NODE_ENV === 'development') {
console.log(`API Response: ${response.status} ${response.statusText}`, {
url,
status: response.status,
})
}
if (!response.ok) {
let errorMessage = `HTTP ${response.status}: ${response.statusText}`
try {
const errorData = await response.json()
errorMessage = errorData.message || errorMessage
} catch {
// If JSON parsing fails, use the default error message
}
throw new Error(errorMessage)
}
if (response.status === 204) {
return null as T
}
const data = await response.json()
if (process.env.NODE_ENV === 'development') {
console.log('API Response Data:', data)
}
return data
} catch (error) {
const apiError: ApiError = {
message:
error instanceof Error ? error.message : 'Unknown error occurred',
status:
error instanceof Error && 'status' in error
? (error as { status?: number }).status
: undefined,
}
if (process.env.NODE_ENV === 'development') {
console.error('API Error:', apiError)
}
throw apiError
}
}
async listTasks(): Promise<Task[]> {
return this.fetchWrapper<Task[]>('/tasks')
}
async getTask(id: string): Promise<Task> {
return this.fetchWrapper<Task>(`/tasks/${id}`)
}
async createTask(data: CreateTaskRequest): Promise<Task> {
return this.fetchWrapper<Task>('/tasks', {
method: 'POST',
body: JSON.stringify(data),
})
}
async updateTask(id: string, data: UpdateTaskRequest): Promise<Task> {
return this.fetchWrapper<Task>(`/tasks/${id}`, {
method: 'PUT',
body: JSON.stringify(data),
})
}
async deleteTask(id: string): Promise<void> {
return this.fetchWrapper<void>(`/tasks/${id}`, {
method: 'DELETE',
})
}
}
export const apiClient = new ApiClient()