Agent turn complete

This commit is contained in:
VibeCoder
2026-09-07 19:32:02 +05:30 Unverified
commit 0a87bf68b5
39 changed files with 4301 additions and 0 deletions
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>App</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+2751
View File
File diff suppressed because it is too large Load Diff
+30
View File
@@ -0,0 +1,30 @@
{
"name": "app-frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"typecheck": "tsc --noEmit",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@emotion/react": "11.14.0",
"@emotion/styled": "11.14.0",
"@mui/icons-material": "6.3.0",
"@mui/material": "6.3.0",
"react": "18.3.1",
"react-beautiful-dnd": "13.1.1",
"react-dom": "18.3.1",
"react-router-dom": "6.28.0"
},
"devDependencies": {
"@types/react": "18.3.18",
"@types/react-beautiful-dnd": "13.1.8",
"@types/react-dom": "18.3.5",
"@vitejs/plugin-react": "4.3.4",
"typescript": "5.7.2",
"vite": "6.0.7"
}
}
+17
View File
@@ -0,0 +1,17 @@
import Container from '@mui/material/Container';
import { Route, Routes } from 'react-router-dom';
import { KanbanBoard } from './components/KanbanBoard';
// Routes are declared here, in one <Routes>, with RELATIVE paths — main.tsx
// supplies the basename. Navigate with <Link to>, never <a href>.
// All styling goes in `sx`: MUI v6 dropped the shorthand props.
export function App() {
return (
<Container maxWidth="xl" sx={{ py: 4 }}>
<Routes>
<Route path="/" element={<KanbanBoard />} />
</Routes>
</Container>
);
}
View File
+78
View File
@@ -0,0 +1,78 @@
import Box from '@mui/material/Box';
import Paper from '@mui/material/Paper';
import Typography from '@mui/material/Typography';
import { Droppable } from 'react-beautiful-dnd';
import { Task } from '../types/task';
import { TaskCard } from './TaskCard';
interface ColumnProps {
title: string;
tasks: Task[];
onTaskClick: (task: Task) => void;
}
const columnColors: Record<string, string> = {
'TODO': '#e3f2fd',
'IN PROGRESS': '#fff3e0',
'QA': '#f3e5f5',
'UAT': '#e8f5e9',
'PROD': '#fce4ec',
};
export function Column({ title, tasks, onTaskClick }: ColumnProps) {
return (
<Paper
sx={{
flex: 1,
minWidth: 300,
backgroundColor: columnColors[title] || '#f5f5f5',
borderRadius: 2,
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
}}
>
<Box
sx={{
p: 2,
backgroundColor: 'rgba(0, 0, 0, 0.05)',
borderBottom: '1px solid rgba(0, 0, 0, 0.1)',
}}
>
<Typography variant="h6" sx={{ fontWeight: 600 }}>
{title}
</Typography>
<Typography variant="caption" color="textSecondary">
{tasks.length} task{tasks.length !== 1 ? 's' : ''}
</Typography>
</Box>
<Droppable droppableId={title}>
{(provided, snapshot) => (
<Box
ref={provided.innerRef}
{...provided.droppableProps}
sx={{
flex: 1,
p: 2,
overflowY: 'auto',
backgroundColor: snapshot.isDraggingOver ? 'rgba(0, 0, 0, 0.05)' : 'transparent',
transition: 'background-color 0.2s ease',
minHeight: 400,
}}
>
{tasks.map((task, index) => (
<TaskCard
key={task.id}
task={task}
index={index}
onClick={onTaskClick}
/>
))}
{provided.placeholder}
</Box>
)}
</Droppable>
</Paper>
);
}
@@ -0,0 +1,142 @@
import Button from '@mui/material/Button';
import Dialog from '@mui/material/Dialog';
import DialogActions from '@mui/material/DialogActions';
import DialogContent from '@mui/material/DialogContent';
import DialogTitle from '@mui/material/DialogTitle';
import FormControl from '@mui/material/FormControl';
import InputLabel from '@mui/material/InputLabel';
import MenuItem from '@mui/material/MenuItem';
import Select from '@mui/material/Select';
import TextField from '@mui/material/TextField';
import { useState } from 'react';
import { Task, PRIORITIES } from '../types/task';
interface CreateTaskDialogProps {
open: boolean;
onClose: () => void;
onCreate: (task: Omit<Task, 'id' | 'created_at' | 'updated_at'>) => void;
}
export function CreateTaskDialog({ open, onClose, onCreate }: CreateTaskDialogProps) {
const [formData, setFormData] = useState({
title: '',
description: '',
priority: 'medium',
status: 'TODO',
assignee: '',
tags: '',
time_estimate: '',
due_date: '',
});
const handleChange = (field: string, value: any) => {
setFormData((prev) => ({
...prev,
[field]: value,
}));
};
const handleCreate = () => {
if (formData.title.trim()) {
onCreate({
title: formData.title,
description: formData.description || undefined,
priority: formData.priority as any,
status: formData.status as any,
assignee: formData.assignee || undefined,
tags: formData.tags || undefined,
time_estimate: formData.time_estimate ? parseFloat(formData.time_estimate) : undefined,
due_date: formData.due_date ? new Date(formData.due_date).toISOString() : undefined,
});
setFormData({
title: '',
description: '',
priority: 'medium',
status: 'TODO',
assignee: '',
tags: '',
time_estimate: '',
due_date: '',
});
onClose();
}
};
return (
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
<DialogTitle>Create New Task</DialogTitle>
<DialogContent sx={{ display: 'flex', flexDirection: 'column', gap: 2, pt: 2 }}>
<TextField
label="Title"
value={formData.title}
onChange={(e) => handleChange('title', e.target.value)}
fullWidth
required
autoFocus
/>
<TextField
label="Description"
value={formData.description}
onChange={(e) => handleChange('description', e.target.value)}
fullWidth
multiline
rows={3}
/>
<FormControl fullWidth>
<InputLabel>Priority</InputLabel>
<Select
value={formData.priority}
onChange={(e) => handleChange('priority', e.target.value)}
label="Priority"
>
{PRIORITIES.map((p) => (
<MenuItem key={p} value={p}>
{p.charAt(0).toUpperCase() + p.slice(1)}
</MenuItem>
))}
</Select>
</FormControl>
<TextField
label="Assignee"
value={formData.assignee}
onChange={(e) => handleChange('assignee', e.target.value)}
fullWidth
/>
<TextField
label="Tags (comma-separated)"
value={formData.tags}
onChange={(e) => handleChange('tags', e.target.value)}
fullWidth
/>
<TextField
label="Time Estimate (hours)"
type="number"
value={formData.time_estimate}
onChange={(e) => handleChange('time_estimate', e.target.value)}
fullWidth
inputProps={{ step: '0.5' }}
/>
<TextField
label="Due Date"
type="datetime-local"
value={formData.due_date}
onChange={(e) => handleChange('due_date', e.target.value)}
fullWidth
InputLabelProps={{ shrink: true }}
/>
</DialogContent>
<DialogActions>
<Button onClick={onClose}>Cancel</Button>
<Button onClick={handleCreate} variant="contained">
Create
</Button>
</DialogActions>
</Dialog>
);
}
+252
View File
@@ -0,0 +1,252 @@
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import Chip from '@mui/material/Chip';
import TextField from '@mui/material/TextField';
import Typography from '@mui/material/Typography';
import AddIcon from '@mui/icons-material/Add';
import SearchIcon from '@mui/icons-material/Search';
import { useEffect, useState } from 'react';
import { DragDropContext, DropResult } from 'react-beautiful-dnd';
import { api } from '../services/api';
import { Task, COLUMNS, TaskListResponse } from '../types/task';
import { Column } from './Column';
import { TaskModal } from './TaskModal';
import { CreateTaskDialog } from './CreateTaskDialog';
export function KanbanBoard() {
const [tasks, setTasks] = useState<Task[]>([]);
const [filteredTasks, setFilteredTasks] = useState<Task[]>([]);
const [selectedTask, setSelectedTask] = useState<Task | null>(null);
const [modalOpen, setModalOpen] = useState(false);
const [createDialogOpen, setCreateDialogOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [selectedPriority, setSelectedPriority] = useState<string | null>(null);
const [selectedTag, setSelectedTag] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// Fetch tasks
const fetchTasks = async () => {
try {
setLoading(true);
setError(null);
const response = await api.get<TaskListResponse>('/api/tasks');
setTasks(response.data.tasks);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to fetch tasks');
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchTasks();
}, []);
// Filter tasks based on search, priority, and tag
useEffect(() => {
let filtered = tasks;
if (searchQuery) {
filtered = filtered.filter(
(task) =>
task.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
task.description?.toLowerCase().includes(searchQuery.toLowerCase())
);
}
if (selectedPriority) {
filtered = filtered.filter((task) => task.priority === selectedPriority);
}
if (selectedTag) {
filtered = filtered.filter((task) =>
task.tags?.split(',').map((t) => t.trim()).includes(selectedTag)
);
}
setFilteredTasks(filtered);
}, [tasks, searchQuery, selectedPriority, selectedTag]);
// Group tasks by status
const tasksByStatus = COLUMNS.reduce(
(acc, column) => {
acc[column] = (filteredTasks || []).filter((task) => task.status === column);
return acc;
},
{} as Record<string, Task[]>
);
// Handle drag and drop
const handleDragEnd = async (result: DropResult) => {
const { source, destination, draggableId } = result;
if (!destination) return;
if (source.droppableId === destination.droppableId && source.index === destination.index) {
return;
}
const taskId = parseInt(draggableId.split('-')[1]);
const task = tasks.find((t) => t.id === taskId);
if (task) {
try {
await api.put(`/api/tasks/${taskId}`, {
...task,
status: destination.droppableId,
});
await fetchTasks();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to update task');
}
}
};
// Handle task creation
const handleCreateTask = async (newTask: Omit<Task, 'id' | 'created_at' | 'updated_at'>) => {
try {
await api.post('/api/tasks', newTask);
await fetchTasks();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to create task');
}
};
// Handle task update
const handleUpdateTask = async (updatedTask: Task) => {
try {
await api.put(`/api/tasks/${updatedTask.id}`, updatedTask);
await fetchTasks();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to update task');
}
};
// Handle task deletion
const handleDeleteTask = async (taskId: number) => {
try {
await api.delete(`/api/tasks/${taskId}`);
await fetchTasks();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to delete task');
}
};
// Get all unique tags
const allTags = Array.from(
new Set(
(tasks || [])
.flatMap((task) => task.tags?.split(',').map((t) => t.trim()) || [])
.filter(Boolean)
)
);
if (loading) {
return (
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '100vh' }}>
<Typography>Loading tasks...</Typography>
</Box>
);
}
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
{/* Header */}
<Box>
<Typography variant="h3" sx={{ fontWeight: 700, mb: 2 }}>
AI Task Kanban Board
</Typography>
{error && (
<Box sx={{ p: 2, mb: 2, backgroundColor: '#ffebee', borderRadius: 1, color: '#c62828' }}>
{error}
</Box>
)}
{/* Controls */}
<Box sx={{ display: 'flex', gap: 2, flexWrap: 'wrap', alignItems: 'center', mb: 2 }}>
<TextField
placeholder="Search tasks..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
size="small"
InputProps={{
startAdornment: <SearchIcon sx={{ mr: 1, color: 'action.active' }} />,
}}
sx={{ flex: 1, minWidth: 250 }}
/>
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
{['low', 'medium', 'high', 'urgent'].map((priority) => (
<Chip
key={priority}
label={priority}
onClick={() => setSelectedPriority(selectedPriority === priority ? null : priority)}
variant={selectedPriority === priority ? 'filled' : 'outlined'}
color={selectedPriority === priority ? 'primary' : 'default'}
/>
))}
</Box>
{allTags.length > 0 && (
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
{allTags.map((tag) => (
<Chip
key={tag}
label={tag}
onClick={() => setSelectedTag(selectedTag === tag ? null : tag)}
variant={selectedTag === tag ? 'filled' : 'outlined'}
color={selectedTag === tag ? 'secondary' : 'default'}
/>
))}
</Box>
)}
<Button
variant="contained"
startIcon={<AddIcon />}
onClick={() => setCreateDialogOpen(true)}
>
New Task
</Button>
</Box>
</Box>
{/* Kanban Board */}
<DragDropContext onDragEnd={handleDragEnd}>
<Box sx={{ display: 'flex', gap: 2, overflowX: 'auto', pb: 2 }}>
{COLUMNS.map((column) => (
<Column
key={column}
title={column}
tasks={tasksByStatus[column]}
onTaskClick={(task) => {
setSelectedTask(task);
setModalOpen(true);
}}
/>
))}
</Box>
</DragDropContext>
{/* Modals */}
<TaskModal
open={modalOpen}
task={selectedTask}
onClose={() => {
setModalOpen(false);
setSelectedTask(null);
}}
onSave={handleUpdateTask}
onDelete={handleDeleteTask}
/>
<CreateTaskDialog
open={createDialogOpen}
onClose={() => setCreateDialogOpen(false)}
onCreate={handleCreateTask}
/>
</Box>
);
}
+86
View File
@@ -0,0 +1,86 @@
import Box from '@mui/material/Box';
import Card from '@mui/material/Card';
import CardContent from '@mui/material/CardContent';
import Chip from '@mui/material/Chip';
import Typography from '@mui/material/Typography';
import { Draggable } from 'react-beautiful-dnd';
import { Task } from '../types/task';
interface TaskCardProps {
task: Task;
index: number;
onClick: (task: Task) => void;
}
const priorityColors: Record<string, 'default' | 'primary' | 'secondary' | 'error' | 'warning' | 'info' | 'success'> = {
low: 'default',
medium: 'info',
high: 'warning',
urgent: 'error',
};
export function TaskCard({ task, index, onClick }: TaskCardProps) {
const tags = task.tags ? task.tags.split(',').map((t) => t.trim()) : [];
return (
<Draggable draggableId={`task-${task.id}`} index={index}>
{(provided, snapshot) => (
<Card
ref={provided.innerRef}
{...provided.draggableProps}
{...provided.dragHandleProps}
onClick={() => onClick(task)}
sx={{
mb: 1.5,
cursor: 'grab',
backgroundColor: snapshot.isDragging ? '#f5f5f5' : '#fff',
boxShadow: snapshot.isDragging ? 3 : 1,
transition: 'all 0.2s ease',
'&:hover': {
boxShadow: 2,
backgroundColor: '#fafafa',
},
}}
>
<CardContent sx={{ pb: 1, '&:last-child': { pb: 1 } }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 0.5 }}>
{task.title}
</Typography>
{task.description && (
<Typography variant="body2" color="textSecondary" sx={{ mb: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{task.description}
</Typography>
)}
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap', mb: 1 }}>
<Chip
label={task.priority}
size="small"
color={priorityColors[task.priority] as any}
variant="outlined"
/>
{task.assignee && (
<Chip label={task.assignee} size="small" variant="outlined" />
)}
</Box>
{tags.length > 0 && (
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap', mb: 1 }}>
{tags.map((tag) => (
<Chip key={tag} label={tag} size="small" variant="filled" sx={{ height: 24 }} />
))}
</Box>
)}
{task.due_date && (
<Typography variant="caption" color="textSecondary">
Due: {new Date(task.due_date).toLocaleDateString()}
</Typography>
)}
</CardContent>
</Card>
)}
</Draggable>
);
}
+162
View File
@@ -0,0 +1,162 @@
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import Dialog from '@mui/material/Dialog';
import DialogActions from '@mui/material/DialogActions';
import DialogContent from '@mui/material/DialogContent';
import DialogTitle from '@mui/material/DialogTitle';
import FormControl from '@mui/material/FormControl';
import InputLabel from '@mui/material/InputLabel';
import MenuItem from '@mui/material/MenuItem';
import Select from '@mui/material/Select';
import TextField from '@mui/material/TextField';
import Typography from '@mui/material/Typography';
import { useEffect, useState } from 'react';
import { Task, COLUMNS, PRIORITIES } from '../types/task';
interface TaskModalProps {
open: boolean;
task: Task | null;
onClose: () => void;
onSave: (task: Task) => void;
onDelete: (taskId: number) => void;
}
export function TaskModal({ open, task, onClose, onSave, onDelete }: TaskModalProps) {
const [formData, setFormData] = useState<Partial<Task>>({});
useEffect(() => {
if (task) {
setFormData(task);
}
}, [task]);
const handleChange = (field: keyof Task, value: any) => {
setFormData((prev) => ({
...prev,
[field]: value,
}));
};
const handleSave = () => {
if (formData.id && formData.title) {
onSave(formData as Task);
onClose();
}
};
const handleDelete = () => {
if (formData.id && window.confirm('Are you sure you want to delete this task?')) {
onDelete(formData.id);
onClose();
}
};
if (!task) return null;
return (
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
<DialogTitle>Task Details</DialogTitle>
<DialogContent sx={{ display: 'flex', flexDirection: 'column', gap: 2, pt: 2 }}>
<TextField
label="Title"
value={formData.title || ''}
onChange={(e) => handleChange('title', e.target.value)}
fullWidth
required
/>
<TextField
label="Description"
value={formData.description || ''}
onChange={(e) => handleChange('description', e.target.value)}
fullWidth
multiline
rows={3}
/>
<FormControl fullWidth>
<InputLabel>Priority</InputLabel>
<Select
value={formData.priority || 'medium'}
onChange={(e) => handleChange('priority', e.target.value)}
label="Priority"
>
{PRIORITIES.map((p) => (
<MenuItem key={p} value={p}>
{p.charAt(0).toUpperCase() + p.slice(1)}
</MenuItem>
))}
</Select>
</FormControl>
<FormControl fullWidth>
<InputLabel>Status</InputLabel>
<Select
value={formData.status || 'TODO'}
onChange={(e) => handleChange('status', e.target.value)}
label="Status"
>
{COLUMNS.map((col) => (
<MenuItem key={col} value={col}>
{col}
</MenuItem>
))}
</Select>
</FormControl>
<TextField
label="Assignee"
value={formData.assignee || ''}
onChange={(e) => handleChange('assignee', e.target.value)}
fullWidth
/>
<TextField
label="Tags (comma-separated)"
value={formData.tags || ''}
onChange={(e) => handleChange('tags', e.target.value)}
fullWidth
/>
<TextField
label="Time Estimate (hours)"
type="number"
value={formData.time_estimate || ''}
onChange={(e) => handleChange('time_estimate', e.target.value ? parseFloat(e.target.value) : undefined)}
fullWidth
inputProps={{ step: '0.5' }}
/>
<TextField
label="Due Date"
type="datetime-local"
value={formData.due_date ? new Date(formData.due_date).toISOString().slice(0, 16) : ''}
onChange={(e) => handleChange('due_date', e.target.value ? new Date(e.target.value).toISOString() : undefined)}
fullWidth
InputLabelProps={{ shrink: true }}
/>
{formData.created_at && (
<Box>
<Typography variant="caption" color="textSecondary">
Created: {new Date(formData.created_at).toLocaleString()}
</Typography>
<br />
<Typography variant="caption" color="textSecondary">
Updated: {new Date(formData.updated_at || '').toLocaleString()}
</Typography>
</Box>
)}
</DialogContent>
<DialogActions>
<Button onClick={handleDelete} color="error">
Delete
</Button>
<Button onClick={onClose}>Cancel</Button>
<Button onClick={handleSave} variant="contained">
Save
</Button>
</DialogActions>
</Dialog>
);
}
+26
View File
@@ -0,0 +1,26 @@
import CssBaseline from '@mui/material/CssBaseline';
import { ThemeProvider } from '@mui/material/styles';
import React from 'react';
import ReactDOM from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import { App } from './App';
import { theme } from './theme';
// The one entry point. Never add src/index.tsx alongside it.
//
// The router lives HERE, and its basename is `import.meta.env.BASE_URL` — never
// a hard-coded string. The app is served from a sub-path that is only known at
// deploy time (`/<project>_frontend/`), and Vite substitutes BASE_URL with it at
// build time. A router with no basename, or one with a literal path, sends every
// link outside the deployed app and the page goes blank.
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<ThemeProvider theme={theme}>
<CssBaseline />
<BrowserRouter basename={import.meta.env.BASE_URL}>
<App />
</BrowserRouter>
</ThemeProvider>
</React.StrictMode>,
);
+45
View File
@@ -0,0 +1,45 @@
/**
* The HTTP client. One place that knows the base URL.
*
* Deliberately dependency-free: the scaffold installs no axios, so an `api.ts`
* that imported it would fail to build. The shape (`.get(...).then(r => r.data)`)
* matches what the fullstack skill's examples use.
*
* VITE_API_URL is the backend BASE, wired at deploy time. Endpoints live under
* `/api`. Never hardcode localhost — it works in dev and breaks on deploy.
*/
const BASE = (import.meta.env.VITE_API_URL ?? '').replace(/\/$/, '');
export type Res<T> = { data: T; status: number };
async function request<T>(method: string, path: string, body?: unknown): Promise<Res<T>> {
const res = await fetch(`${BASE}${path}`, {
method,
headers: body === undefined ? undefined : { 'Content-Type': 'application/json' },
body: body === undefined ? undefined : JSON.stringify(body),
});
if (!res.ok) {
let detail = `${res.status} ${res.statusText}`;
try {
const j = (await res.json()) as { detail?: unknown };
if (typeof j.detail === 'string') detail = j.detail;
} catch {
/* keep the status line */
}
throw new Error(detail);
}
// 204 has no body — reading it as JSON throws, and a delete that "fails"
// because it succeeded is a confusing bug to chase.
const data = res.status === 204 ? (undefined as T) : ((await res.json()) as T);
return { data, status: res.status };
}
export const api = {
get: <T>(path: string) => request<T>('GET', path),
post: <T>(path: string, body?: unknown) => request<T>('POST', path, body ?? {}),
put: <T>(path: string, body?: unknown) => request<T>('PUT', path, body ?? {}),
patch: <T>(path: string, body?: unknown) => request<T>('PATCH', path, body ?? {}),
delete: <T>(path: string) => request<T>('DELETE', path),
};
export const apiBaseUrl = () => BASE;
+74
View File
@@ -0,0 +1,74 @@
/**
* Mock-first data resolution.
*
* The Preview tab runs this frontend ALONE, in a browser sandbox, with no
* backend behind it. A UI whose every screen starts with a fetch shows an
* error or an empty list there — which is what the app is judged by, seconds
* after the build says it finished.
*
* So every data call goes through `resolve(real, mock)`: it serves the mock
* until a health check says the backend is answering, then serves the real
* call. No code change, no reload, no flag to flip.
*
* const todos = await resolve(
* () => api.get<Todo[]>('/api/todos').then((r) => r.data),
* SEED.todos,
* );
*
* The seed rows live in ./mockData.ts. src/App.tsx (React) / src/views (Vue)
* already do this — copy that call, do not invent a second way.
*/
import { api } from './api';
/** Re-checked rather than cached forever: the backend appears mid-session, and
* a resolver that decided "no backend" once would never notice. */
const TTL_MS = 5_000;
let liveUntil = 0;
let live = false;
let inFlight: Promise<boolean> | null = null;
async function probe(): Promise<boolean> {
try {
const res = await api.get<unknown>('/api/health');
return res.status >= 200 && res.status < 300;
} catch {
return false;
}
}
/** Whether the backend is currently answering. Shared across callers, so ten
* components mounting together make one health request, not ten. */
export async function backendIsLive(): Promise<boolean> {
if (Date.now() < liveUntil) return live;
if (!inFlight) {
inFlight = probe().then((ok) => {
live = ok;
liveUntil = Date.now() + TTL_MS;
inFlight = null;
return ok;
});
}
return inFlight;
}
/**
* The real call when the backend is up, the mock otherwise.
*
* A real call that THROWS also falls back, rather than propagating: a backend
* that is up but broken should still leave a usable screen, and the caller
* cannot tell the difference anyway.
*/
export async function resolve<T>(real: () => Promise<T>, mock: T): Promise<T> {
if (!(await backendIsLive())) return mock;
try {
return await real();
} catch {
return mock;
}
}
/** For a status chip: "Demo data" vs "Live". Never leave the user guessing
* which one they are looking at. */
export const dataSourceLabel = async (): Promise<'live' | 'demo'> =>
(await backendIsLive()) ? 'live' : 'demo';
+12
View File
@@ -0,0 +1,12 @@
/**
* Seed rows for the Preview tab, which runs the frontend with no backend.
* Replace with rows that match the app being built — "foo / bar" reads as broken.
*/
export type Item = { id: number; name: string; status: string };
export const SEED: { items: Item[] } = {
items: [
{ id: 1, name: 'Draft the launch checklist', status: 'in progress' },
{ id: 2, name: 'Review onboarding copy', status: 'todo' },
],
};
+52
View File
@@ -0,0 +1,52 @@
import { createTheme } from '@mui/material/styles';
// The app's design tokens, in one place.
//
// This is the ONLY file allowed to contain a raw colour literal. Everywhere
// else, reach for a token: `sx={{ color: 'text.secondary' }}`,
// `sx={{ bgcolor: 'background.paper' }}`, `sx={{ p: 2 }}`. A hex code written
// inline is invisible to dark mode, invisible to a later restyle, and drifts
// from the value three components over.
//
// Adjust these values to suit the app being built — a finance dashboard and a
// children's reading list should not look identical. Add tokens; do not scatter
// them.
export const theme = createTheme({
palette: {
mode: 'light',
primary: { main: '#2f5fe0', light: '#6b8bf0', dark: '#1f3f9b', contrastText: '#ffffff' },
secondary: { main: '#7b4bd8', light: '#a582e8', dark: '#54309b', contrastText: '#ffffff' },
success: { main: '#1f8a5b' },
warning: { main: '#b8760a' },
error: { main: '#c62b3d' },
info: { main: '#0d7490' },
background: { default: '#f6f7fb', paper: '#ffffff' },
text: { primary: '#151a2d', secondary: '#5a6280' },
divider: '#e2e5ef',
},
// 8px rhythm: `sx={{ p: 2 }}` is 16px, `sx={{ gap: 1 }}` is 8px.
spacing: 8,
shape: { borderRadius: 10 },
typography: {
fontFamily: '"Inter", "Segoe UI", system-ui, -apple-system, sans-serif',
h1: { fontSize: '2.25rem', fontWeight: 700, letterSpacing: '-0.02em' },
h2: { fontSize: '1.75rem', fontWeight: 700, letterSpacing: '-0.015em' },
h3: { fontSize: '1.5rem', fontWeight: 600 },
h4: { fontSize: '1.25rem', fontWeight: 600 },
h5: { fontSize: '1.125rem', fontWeight: 600 },
h6: { fontSize: '1rem', fontWeight: 600 },
body1: { fontSize: '0.9375rem', lineHeight: 1.6 },
body2: { fontSize: '0.875rem', lineHeight: 1.6 },
button: { textTransform: 'none', fontWeight: 600 },
},
components: {
MuiButton: { defaultProps: { disableElevation: true } },
MuiPaper: { defaultProps: { elevation: 0 }, styleOverrides: { root: { backgroundImage: 'none' } } },
MuiCard: { defaultProps: { variant: 'outlined' } },
MuiTextField: { defaultProps: { size: 'small' } },
},
});
+25
View File
@@ -0,0 +1,25 @@
/**
* Task types for the kanban board.
*/
export interface Task {
id: number;
title: string;
description?: string;
priority: 'low' | 'medium' | 'high' | 'urgent';
status: 'TODO' | 'IN PROGRESS' | 'QA' | 'UAT' | 'PROD';
assignee?: string;
tags?: string;
time_estimate?: number;
due_date?: string;
created_at: string;
updated_at: string;
}
export interface TaskListResponse {
tasks: Task[];
total: number;
}
export const COLUMNS = ['TODO', 'IN PROGRESS', 'QA', 'UAT', 'PROD'] as const;
export const PRIORITIES = ['low', 'medium', 'high', 'urgent'] as const;
+20
View File
@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2020",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"skipLibCheck": true,
"isolatedModules": true,
"resolveJsonModule": true,
"allowImportingTsExtensions": false,
"noEmit": true,
"types": ["vite/client"]
},
"include": ["src"]
}
+16
View File
@@ -0,0 +1,16 @@
import react from '@vitejs/plugin-react';
import { defineConfig } from 'vite';
// Deploy owns `base` and the /api proxy. Changing them here breaks the deployed
// app while dev keeps working — the worst kind of change to make.
export default defineConfig({
plugins: [react()],
server: {
proxy: {
'/api': {
target: process.env.VITE_API_URL || 'http://localhost:8000',
changeOrigin: true,
},
},
},
});