3 Commits

Author SHA1 Message Date
Roberto Musso
5bd9d72cc6 feat: add PriorityBadge component and integrate into TaskRow
- Implemented PriorityBadge component to display task priority with icons.
- Created TaskRow component to represent individual tasks with metadata.
- Added breadcrumb navigation for task hierarchy.
- Enhanced checkbox component to support indeterminate state.
- Introduced InputGroup for better input handling in task search.
- Updated tasks route to utilize new components and improve UI.
- Added empty state representation for task list.
2026-02-20 22:23:46 +01:00
Roberto Musso
ab517549a9 feat: US-012 — GanttChart SVG component and global Timeline view
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 12:47:34 +01:00
Roberto Musso
e92d58a46e feat: US-011 — Global Tasks view UI
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 12:43:42 +01:00
24 changed files with 3080 additions and 18 deletions

45
package-lock.json generated
View File

@@ -19,6 +19,7 @@
"better-sqlite3": "^12.6.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"drizzle-orm": "^0.45.1",
"electron-squirrel-startup": "^1.0.1",
"electron-store": "^8.2.0",
@@ -26,6 +27,7 @@
"lucide-react": "^0.575.0",
"radix-ui": "^1.4.3",
"react": "^19.2.4",
"react-day-picker": "^9.13.2",
"react-dom": "^19.2.4",
"tailwind-merge": "^3.5.0",
"tw-animate-css": "^1.4.0",
@@ -608,6 +610,12 @@
"node": ">=6.9.0"
}
},
"node_modules/@date-fns/tz": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/@date-fns/tz/-/tz-1.4.1.tgz",
"integrity": "sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA==",
"license": "MIT"
},
"node_modules/@dotenvx/dotenvx": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/@dotenvx/dotenvx/-/dotenvx-1.52.0.tgz",
@@ -8728,6 +8736,22 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/date-fns": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
"integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/kossnocorp"
}
},
"node_modules/date-fns-jalali": {
"version": "4.1.0-0",
"resolved": "https://registry.npmjs.org/date-fns-jalali/-/date-fns-jalali-4.1.0-0.tgz",
"integrity": "sha512-hTIP/z+t+qKwBDcmmsnmjWTduxCg+5KfdqWQvb2X/8C9+knYY6epN/pfxdDuyVlSVeFz0sM5eEfwIUQ70U4ckg==",
"license": "MIT"
},
"node_modules/debounce-fn": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/debounce-fn/-/debounce-fn-4.0.0.tgz",
@@ -15906,6 +15930,27 @@
"node": ">=0.10.0"
}
},
"node_modules/react-day-picker": {
"version": "9.13.2",
"resolved": "https://registry.npmjs.org/react-day-picker/-/react-day-picker-9.13.2.tgz",
"integrity": "sha512-IMPiXfXVIAuR5Yk58DDPBC8QKClrhdXV+Tr/alBrwrHUw0qDDYB1m5zPNuTnnPIr/gmJ4ChMxmtqPdxm8+R4Eg==",
"license": "MIT",
"dependencies": {
"@date-fns/tz": "^1.4.1",
"date-fns": "^4.1.0",
"date-fns-jalali": "^4.1.0-0"
},
"engines": {
"node": ">=18"
},
"funding": {
"type": "individual",
"url": "https://github.com/sponsors/gpbl"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/react-dom": {
"version": "19.2.4",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",

View File

@@ -55,6 +55,7 @@
"better-sqlite3": "^12.6.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"drizzle-orm": "^0.45.1",
"electron-squirrel-startup": "^1.0.1",
"electron-store": "^8.2.0",
@@ -62,6 +63,7 @@
"lucide-react": "^0.575.0",
"radix-ui": "^1.4.3",
"react": "^19.2.4",
"react-day-picker": "^9.13.2",
"react-dom": "^19.2.4",
"tailwind-merge": "^3.5.0",
"tw-animate-css": "^1.4.0",

View File

@@ -202,8 +202,8 @@
"Verify in browser using dev-browser skill"
],
"priority": 11,
"passes": false,
"notes": ""
"passes": true,
"notes": "Completed: Global Tasks view with 4 stat cards (Card, CardHeader, CardTitle, CardContent), search with 300ms debounce (Input + Search icon), status filter tabs (Tabs/TabsList/TabsTrigger: All/To Do/In Progress/Completed), Order by dropdown (DropdownMenu: Due Date/Priority/Created Date), task rows with Checkbox toggle (todo↔done), priority Badge (destructive/secondary/outline variants), due date chip, breadcrumb (Client > Sub-Client > Project), assignee. Completed rows green-tinted. NewTaskDialog component with title Input, Textarea description, Select priority/status, Popover+Calendar due date, Select project, Input assignee. All shadcn/ui primitives installed: card, tabs, checkbox, badge, textarea, popover, calendar."
},
{
"id": "US-012",
@@ -223,8 +223,8 @@
"Verify in browser using dev-browser skill"
],
"priority": 12,
"passes": false,
"notes": ""
"passes": true,
"notes": "Completed: Reusable GanttChart SVG component with month labels, baseline, ResizeObserver responsive width, Today marker (red line), checkpoint dots (dark=#171717 for future/approved, green=#16a34a for past/approved, dashed outline for pending AI suggestions). Popover on dot click shows title, date, project name, delete button. Global Timeline route (/timeline) renders all checkpoints from all projects with project name lookup via Map. AddCheckpointDialog with title Input, Popover+Calendar date, Select project. Legend showing dot types."
},
{
"id": "US-013",
@@ -242,8 +242,8 @@
"Verify in browser using dev-browser skill"
],
"priority": 13,
"passes": false,
"notes": ""
"passes": true,
"notes": "Completed: Project Detail view with Breadcrumb (Client > Sub-Client path from clients list), H1 project name, 3 stat cards (Notes count, Tasks Complete done/total, Checkpoints approved/total), AI Project Summary card with sparkles icon showing aiSummary or placeholder text. All data fetched via tRPC queries scoped to projectId. shadcn/ui breadcrumb installed."
},
{
"id": "US-014",

View File

@@ -15,6 +15,10 @@
- ESLint uses `eslint-import-resolver-typescript` to resolve `@/*` aliases; configured in `.eslintrc.json` under `settings.import/resolver`
- App settings (sidebar state, etc.) exposed via `settings` tRPC sub-router for type-safe renderer access
- `z.string().nullable().optional()` in tRPC inputs enables three-state semantics: undefined = don't change, null = clear, string = set value
- NewTaskDialog component at `src/renderer/components/tasks/NewTaskDialog.tsx` accepts `defaultProjectId` and `defaultStatus` props for reuse in Kanban column "+ Add" buttons
- `date-fns` is available as a transitive dependency of `react-day-picker` (shadcn/ui calendar)
- GanttChart component at `src/renderer/components/timeline/GanttChart.tsx` is reusable: accepts `defaultProjectId` to scope to a project (for US-015 inline timeline)
- AddCheckpointDialog at `src/renderer/components/timeline/AddCheckpointDialog.tsx` accepts `defaultProjectId` — hides project select when provided
- TanStack Router `validateSearch` with Zod schema for passing selected-item IDs via URL search params (e.g., `?projectId=...`)
---
@@ -181,3 +185,44 @@
- `projects.update` with `clientId: z.string().nullable().optional()` allows three states: undefined (don't change), null (unlink), string (assign)
- Auto-expanding all groups during search (`effectiveExpanded` computed from grouped keys) gives a better UX than forcing users to manually expand
---
## 2026-02-20 - US-011
- What was implemented:
- Full Global Tasks view UI at `/tasks` route
- 4 stat cards (Total Tasks, To Do, In Progress, Completed) using shadcn/ui Card components with Lucide icons, reactively updated from unfiltered `tasks.list` query
- Search bar with 300ms debounce using shadcn/ui Input + Search icon
- Status filter tabs using shadcn/ui Tabs (All | To Do | In Progress | Completed)
- "Order by" dropdown using shadcn/ui DropdownMenu (Due Date | Priority | Created Date)
- Task rows with: shadcn/ui Checkbox (toggles todo↔done), title (bold 14px), description (muted truncated), priority Badge (HIGH=destructive, MEDIUM=secondary, LOW=outline green), due date chip (calendar icon + formatted date), breadcrumb (Client > Sub-Client > Project with ChevronRight separators), assignee (User icon + name)
- Completed task rows have green-tinted background (`bg-green-50 border-green-200`)
- NewTaskDialog component with: Input for title (required), Textarea for description, Select for priority/status, Popover+Calendar for due date, Select for project (from `projects.listAll`), Input for assignee
- Installed shadcn/ui components: card, tabs, checkbox, badge, textarea, popover, calendar
- Files changed: `src/renderer/routes/tasks.tsx`, `src/renderer/components/tasks/NewTaskDialog.tsx` (new), `src/renderer/components/ui/card.tsx` (new), `src/renderer/components/ui/tabs.tsx` (new), `src/renderer/components/ui/checkbox.tsx` (new), `src/renderer/components/ui/badge.tsx` (new), `src/renderer/components/ui/textarea.tsx` (new), `src/renderer/components/ui/popover.tsx` (new), `src/renderer/components/ui/calendar.tsx` (new), `scripts/ralph/prd.json`, `scripts/ralph/progress.txt`
- **Learnings for future iterations:**
- Use two separate `tasks.list` queries: one unfiltered `{}` for stat card counts, one with filters for the displayed list — ensures stats always reflect total counts
- `date-fns` `format(date, 'PPP')` produces "February 20th, 2026" style dates — already installed as a dependency of react-day-picker (shadcn/ui calendar)
- shadcn/ui Select with an empty string value (`<SelectItem value="">No project</SelectItem>`) works as a "none" option for optional fields
- The Popover+Calendar date picker pattern is standard shadcn/ui: Popover wraps a Button trigger showing the formatted date, PopoverContent contains the Calendar
- Electron app runs at `http://localhost:5173` in dev mode but only within the Electron BrowserWindow — Playwright browser testing requires the Electron-specific test harness, not direct URL navigation
---
## 2026-02-20 - US-012
- What was implemented:
- Reusable `GanttChart` SVG component at `src/renderer/components/timeline/GanttChart.tsx`
- Accepts `{ checkpoints: GanttCheckpoint[], startDate: Date, endDate: Date, onDelete? }` props
- Custom SVG rendering: month labels on X axis, horizontal baseline `<line>`, `<circle>` dots for checkpoints positioned by date
- Dot fill logic: dark (#171717) for future approved checkpoints, green (#16a34a) for past approved, dashed outline (#737373) for pending AI suggestions (isApproved=0)
- Vertical red "Today" marker line at current date
- ResizeObserver for responsive SVG width
- foreignObject + shadcn/ui Popover on each dot click: shows title, formatted date, project name, and destructive Delete button
- `AddCheckpointDialog` component at `src/renderer/components/timeline/AddCheckpointDialog.tsx`: title Input (required), Popover+Calendar date (required), Select project dropdown (required in global view, hidden when `defaultProjectId` provided)
- Global Timeline route (`/timeline`) renders GanttChart with all checkpoints, project name lookup via Map from `projects.listAll`
- Legend showing dot types, empty state message when no checkpoints
- Files changed: `src/renderer/components/timeline/GanttChart.tsx` (new), `src/renderer/components/timeline/AddCheckpointDialog.tsx` (new), `src/renderer/routes/timeline.tsx`
- **Learnings for future iterations:**
- `foreignObject` inside SVG is the cleanest way to embed React components (like Popover) on SVG elements — set `overflow-visible` class to prevent clipping
- Checkpoints don't have a `status` field; use `isApproved=1` + `date < now` heuristic for "completed" vs "todo" dot color
- Date range for the Gantt is computed dynamically: 1 month before earliest date, 2 months after latest date — ensures comfortable visual padding
- GanttChart is designed for reuse: the `defaultProjectId` prop on AddCheckpointDialog pre-selects the project and hides the dropdown (for per-project timeline in US-015)
- `trpc.projects.listAll.useQuery(undefined, { enabled: showProjectSelect })` prevents unnecessary queries when project is already known
---

View File

@@ -230,13 +230,37 @@ const tasksRouter = router({
.all();
}),
listAssignees: publicProcedure.query(() => {
const rows = getDb()
.select({ assignee: tasks.assignee })
.from(tasks)
.all();
const names = new Set<string>();
for (const row of rows) {
if (!row.assignee) continue;
try {
const parsed = JSON.parse(row.assignee) as unknown;
if (Array.isArray(parsed)) {
for (const n of parsed) {
if (typeof n === 'string' && n) names.add(n);
}
} else {
names.add(row.assignee);
}
} catch {
names.add(row.assignee);
}
}
return [...names].sort();
}),
create: publicProcedure
.input(z.object({
title: z.string(),
description: z.string().optional(),
status: z.string().optional(),
priority: z.string().optional(),
assignee: z.string().optional(),
assignees: z.array(z.string()).optional(),
dueDate: z.number().optional(),
projectId: z.string().optional(),
}))
@@ -249,7 +273,7 @@ const tasksRouter = router({
description: input.description ?? null,
status: input.status ?? 'todo',
priority: input.priority ?? 'medium',
assignee: input.assignee ?? null,
assignee: input.assignees?.length ? JSON.stringify(input.assignees) : null,
dueDate: input.dueDate ?? null,
projectId: input.projectId ?? null,
createdAt: now,
@@ -264,7 +288,7 @@ const tasksRouter = router({
description: z.string().optional(),
status: z.string().optional(),
priority: z.string().optional(),
assignee: z.string().optional(),
assignees: z.array(z.string()).optional(),
dueDate: z.number().optional(),
projectId: z.string().optional(),
}))
@@ -282,7 +306,7 @@ const tasksRouter = router({
if (input.description !== undefined) set.description = input.description;
if (input.status !== undefined) set.status = input.status;
if (input.priority !== undefined) set.priority = input.priority;
if (input.assignee !== undefined) set.assignee = input.assignee;
if (input.assignees !== undefined) set.assignee = input.assignees.length ? JSON.stringify(input.assignees) : null;
if (input.dueDate !== undefined) set.dueDate = input.dueDate;
if (input.projectId !== undefined) set.projectId = input.projectId;
if (Object.keys(set).length > 0) {

View File

@@ -1,4 +1,13 @@
import { useMemo } from 'react';
import { Sparkles, FileText, CheckCircle2, Milestone } from 'lucide-react';
import { trpc } from '@/lib/trpc';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbList,
BreadcrumbSeparator,
} from '@/components/ui/breadcrumb';
type ProjectDetailProps = {
projectId: string;
@@ -6,6 +15,41 @@ type ProjectDetailProps = {
export function ProjectDetail({ projectId }: ProjectDetailProps) {
const { data: project, isLoading } = trpc.projects.get.useQuery({ id: projectId });
const { data: clientsList } = trpc.clients.list.useQuery();
const { data: notesList } = trpc.notes.list.useQuery({ projectId });
const { data: tasksList } = trpc.tasks.list.useQuery({ projectId });
const { data: checkpointsList } = trpc.checkpoints.list.useQuery({ projectId });
// Build breadcrumb path: Client > Sub-Client
const breadcrumbPath = useMemo(() => {
if (!project?.clientId || !clientsList) return [];
const clientMap = new Map(clientsList.map((c) => [c.id, c]));
const client = clientMap.get(project.clientId);
if (!client) return [];
// If client has a parent, show parent > client
if (client.parentId) {
const parent = clientMap.get(client.parentId);
if (parent) return [parent.name, client.name];
}
return [client.name];
}, [project?.clientId, clientsList]);
// Compute stats
const notesCount = notesList?.length ?? 0;
const taskStats = useMemo(() => {
const all = tasksList ?? [];
const done = all.filter((t) => t.status === 'done').length;
return { done, total: all.length };
}, [tasksList]);
const checkpointStats = useMemo(() => {
const all = checkpointsList ?? [];
const approved = all.filter((c) => c.isApproved === 1).length;
return { approved, total: all.length };
}, [checkpointsList]);
if (isLoading) {
return (
@@ -24,11 +68,81 @@ export function ProjectDetail({ projectId }: ProjectDetailProps) {
}
return (
<div className="p-6 max-w-4xl mx-auto">
<div className="p-6 max-w-4xl mx-auto flex flex-col gap-6">
{/* Breadcrumb */}
{breadcrumbPath.length > 0 && (
<Breadcrumb>
<BreadcrumbList>
{breadcrumbPath.map((segment, i) => (
<BreadcrumbItem key={i}>
{i > 0 && <BreadcrumbSeparator />}
<span className="text-muted-foreground">{segment}</span>
</BreadcrumbItem>
))}
</BreadcrumbList>
</Breadcrumb>
)}
{/* Project Name */}
<h1 className="text-2xl font-semibold text-foreground">{project.name}</h1>
<p className="text-sm text-muted-foreground mt-1">
Project detail view will be implemented in US-013.
</p>
{/* Stat Cards */}
<div className="grid grid-cols-3 gap-4">
<Card className="py-4">
<CardHeader className="pb-0 pt-0">
<div className="flex items-center gap-2">
<FileText className="h-5 w-5 text-muted-foreground" />
<CardTitle className="text-sm font-medium text-muted-foreground">Notes</CardTitle>
</div>
</CardHeader>
<CardContent className="pt-0">
<div className="text-2xl font-semibold">{notesCount}</div>
</CardContent>
</Card>
<Card className="py-4">
<CardHeader className="pb-0 pt-0">
<div className="flex items-center gap-2">
<CheckCircle2 className="h-5 w-5 text-muted-foreground" />
<CardTitle className="text-sm font-medium text-muted-foreground">Tasks Complete</CardTitle>
</div>
</CardHeader>
<CardContent className="pt-0">
<div className="text-2xl font-semibold">
{taskStats.done}/{taskStats.total}
</div>
</CardContent>
</Card>
<Card className="py-4">
<CardHeader className="pb-0 pt-0">
<div className="flex items-center gap-2">
<Milestone className="h-5 w-5 text-muted-foreground" />
<CardTitle className="text-sm font-medium text-muted-foreground">Checkpoints</CardTitle>
</div>
</CardHeader>
<CardContent className="pt-0">
<div className="text-2xl font-semibold">
{checkpointStats.approved}/{checkpointStats.total}
</div>
</CardContent>
</Card>
</div>
{/* AI Project Summary */}
<Card>
<CardHeader>
<div className="flex items-center gap-2">
<Sparkles className="h-5 w-5 text-muted-foreground" />
<CardTitle className="text-sm font-medium">AI Project Summary</CardTitle>
</div>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
{project.aiSummary || 'AI summary will appear here'}
</p>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,340 @@
import { useState, useEffect } from 'react';
import { format } from 'date-fns';
import { Calendar as CalendarIcon, X, UserPlus, Check } from 'lucide-react';
import { trpc } from '@/lib/trpc';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Calendar } from '@/components/ui/calendar';
import { Badge } from '@/components/ui/badge';
import { Separator } from '@/components/ui/separator';
import { cn } from '@/lib/utils';
import type { TaskItem } from './TaskRow';
function parseAssigneesLocal(raw: string | null): string[] {
if (!raw) return [];
try {
const parsed = JSON.parse(raw) as unknown;
if (Array.isArray(parsed)) return parsed.filter((n): n is string => typeof n === 'string');
} catch { /* plain string fallback */ }
return [raw];
}
interface EditTaskDialogProps {
task: TaskItem | null;
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function EditTaskDialog({ task, open, onOpenChange }: EditTaskDialogProps) {
const [title, setTitle] = useState('');
const [description, setDescription] = useState('');
const [priority, setPriority] = useState('medium');
const [status, setStatus] = useState('todo');
const [dueDate, setDueDate] = useState<Date | undefined>();
const [dueTime, setDueTime] = useState('');
const [projectId, setProjectId] = useState('');
const [assignees, setAssignees] = useState<string[]>([]);
const [assigneeInput, setAssigneeInput] = useState('');
const [assigneePopoverOpen, setAssigneePopoverOpen] = useState(false);
// Pre-fill fields whenever the task changes
useEffect(() => {
if (!task) return;
setTitle(task.title);
setDescription(task.description ?? '');
setPriority(task.priority ?? 'medium');
setStatus(task.status ?? 'todo');
if (task.dueDate) {
const d = new Date(task.dueDate);
setDueDate(d);
setDueTime(
`${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`,
);
} else {
setDueDate(undefined);
setDueTime('');
}
setProjectId(task.projectId ?? '');
setAssignees(parseAssigneesLocal(task.assignee));
setAssigneeInput('');
setAssigneePopoverOpen(false);
}, [task]);
const { data: projectsList } = trpc.projects.listAll.useQuery();
const { data: knownAssignees = [] } = trpc.tasks.listAssignees.useQuery();
const utils = trpc.useUtils();
const updateTask = trpc.tasks.update.useMutation({
onSuccess: () => {
void utils.tasks.list.invalidate();
onOpenChange(false);
},
});
function addNewAssignee() {
const name = assigneeInput.trim();
if (!name || assignees.includes(name)) return;
setAssignees((prev) => [...prev, name]);
setAssigneeInput('');
}
function toggleAssignee(name: string) {
setAssignees((prev) =>
prev.includes(name) ? prev.filter((a) => a !== name) : [...prev, name],
);
}
function removeAssignee(name: string) {
setAssignees((prev) => prev.filter((a) => a !== name));
}
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
if (!task || !title.trim()) return;
let resolvedDueDate: number | undefined;
if (dueDate) {
const d = new Date(dueDate);
if (dueTime) {
const parts = dueTime.split(':');
const h = parseInt(parts[0] ?? '0', 10);
const m = parseInt(parts[1] ?? '0', 10);
d.setHours(h, m, 0, 0);
}
resolvedDueDate = d.getTime();
}
updateTask.mutate({
id: task.id,
title: title.trim(),
description: description.trim() || undefined,
priority,
status,
dueDate: resolvedDueDate,
projectId: projectId || undefined,
assignees: assignees.length ? assignees : undefined,
});
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[560px]" aria-describedby={undefined}>
<DialogHeader>
<DialogTitle>Edit Task</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
{/* Title */}
<Input
placeholder="Task title"
value={title}
onChange={(e) => setTitle(e.target.value)}
required
autoFocus
/>
{/* Description */}
<Textarea
placeholder="Description (optional)"
value={description}
onChange={(e) => setDescription(e.target.value)}
className="min-h-20"
/>
{/* Priority */}
<Select value={priority} onValueChange={setPriority}>
<SelectTrigger className="w-full">
<SelectValue placeholder="Priority" />
</SelectTrigger>
<SelectContent>
<SelectItem value="high">High</SelectItem>
<SelectItem value="medium">Medium</SelectItem>
<SelectItem value="low">Low</SelectItem>
</SelectContent>
</Select>
{/* Status */}
<Select value={status} onValueChange={setStatus}>
<SelectTrigger className="w-full">
<SelectValue placeholder="Status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="todo">To Do</SelectItem>
<SelectItem value="in_progress">In Progress</SelectItem>
<SelectItem value="done">Completed</SelectItem>
</SelectContent>
</Select>
{/* Due Date + Time */}
<div className="flex flex-col gap-2">
<Popover>
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
className={cn(
'justify-start text-left font-normal',
!dueDate && 'text-muted-foreground',
)}
>
<CalendarIcon className="mr-2 h-4 w-4" />
{dueDate ? format(dueDate, 'PPP') : 'Pick a due date'}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={dueDate}
onSelect={setDueDate}
/>
<div className="border-t px-3 py-2">
<label className="text-xs text-muted-foreground mb-1 block">Time (optional)</label>
<Input
type="time"
value={dueTime}
onChange={(e) => setDueTime(e.target.value)}
className="h-8 text-sm"
/>
</div>
</PopoverContent>
</Popover>
{dueDate && dueTime && (
<p className="text-xs text-muted-foreground pl-1">
Due: {format(dueDate, 'PPP')} at {dueTime}
</p>
)}
</div>
{/* Project */}
<Select
value={projectId || 'none'}
onValueChange={(v) => setProjectId(v === 'none' ? '' : v)}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Project (optional)" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">No project</SelectItem>
{projectsList?.map((p) => (
<SelectItem key={p.id} value={p.id}>
{p.name}
</SelectItem>
))}
</SelectContent>
</Select>
{/* Assignees */}
<div className="flex flex-col gap-2">
{assignees.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{assignees.map((name) => (
<Badge key={name} variant="secondary" className="gap-1 pr-1">
{name}
<button
type="button"
onClick={() => removeAssignee(name)}
className="ml-0.5 rounded-sm opacity-70 hover:opacity-100"
>
<X className="h-3 w-3" />
</button>
</Badge>
))}
</div>
)}
<Popover open={assigneePopoverOpen} onOpenChange={setAssigneePopoverOpen}>
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
className={cn(
'justify-start font-normal',
assignees.length === 0 && 'text-muted-foreground',
)}
>
<UserPlus className="mr-2 h-4 w-4" />
{assignees.length > 0
? `${assignees.length} assignee${assignees.length > 1 ? 's' : ''}`
: 'Add assignees'}
</Button>
</PopoverTrigger>
<PopoverContent className="w-64 p-2" align="start">
{knownAssignees.length > 0 && (
<div className="max-h-36 overflow-y-auto flex flex-col gap-0.5 mb-2">
{knownAssignees.map((name) => (
<Button
key={name}
type="button"
variant="ghost"
size="sm"
className="justify-start h-8 px-2"
onClick={() => toggleAssignee(name)}
>
{assignees.includes(name) ? (
<Check className="h-3 w-3 mr-2 text-primary shrink-0" />
) : (
<span className="w-5 shrink-0" />
)}
<span className="truncate">{name}</span>
</Button>
))}
</div>
)}
{knownAssignees.length === 0 && (
<p className="text-xs text-muted-foreground px-2 py-1 mb-2">No existing assignees</p>
)}
<Separator className="mb-2" />
<div className="flex gap-2">
<Input
placeholder="New name…"
value={assigneeInput}
onChange={(e) => setAssigneeInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
addNewAssignee();
}
}}
className="h-8 text-sm"
/>
<Button
type="button"
size="sm"
onClick={addNewAssignee}
disabled={!assigneeInput.trim()}
>
Add
</Button>
</div>
</PopoverContent>
</Popover>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button type="submit" disabled={!title.trim() || updateTask.isPending}>
{updateTask.isPending ? 'Saving…' : 'Save Changes'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,578 @@
import { useState, useMemo } from 'react';
import { format } from 'date-fns';
import { Calendar as CalendarIcon, X, UserPlus, Check, Plus } from 'lucide-react';
import { trpc } from '@/lib/trpc';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Calendar } from '@/components/ui/calendar';
import { Badge } from '@/components/ui/badge';
import { Separator } from '@/components/ui/separator';
import { cn } from '@/lib/utils';
const NO_CLIENT = '__no_client__';
interface NewTaskDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
defaultProjectId?: string;
defaultStatus?: string;
}
export function NewTaskDialog({ open, onOpenChange, defaultProjectId, defaultStatus }: NewTaskDialogProps) {
const [title, setTitle] = useState('');
const [description, setDescription] = useState('');
const [priority, setPriority] = useState('medium');
const [status, setStatus] = useState(defaultStatus ?? 'todo');
const [dueDate, setDueDate] = useState<Date | undefined>();
const [dueTime, setDueTime] = useState('');
const [projectId, setProjectId] = useState(defaultProjectId ?? '');
// Multi-assignee state
const [assignees, setAssignees] = useState<string[]>([]);
const [assigneeInput, setAssigneeInput] = useState('');
const [assigneePopoverOpen, setAssigneePopoverOpen] = useState(false);
// Inline project creation state
const [creatingProject, setCreatingProject] = useState(false);
const [newProjectName, setNewProjectName] = useState('');
const [newProjectClientId, setNewProjectClientId] = useState(NO_CLIENT);
const [newProjectSubClientId, setNewProjectSubClientId] = useState(NO_CLIENT);
const [creatingClient, setCreatingClient] = useState(false);
const [newClientName, setNewClientName] = useState('');
const [creatingSubClient, setCreatingSubClient] = useState(false);
const [newSubClientName, setNewSubClientName] = useState('');
const { data: projectsList } = trpc.projects.listAll.useQuery();
const { data: clientList = [] } = trpc.clients.list.useQuery();
const { data: knownAssignees = [] } = trpc.tasks.listAssignees.useQuery();
const utils = trpc.useUtils();
const topLevelClients = useMemo(() => clientList.filter((c) => !c.parentId), [clientList]);
const subClientsByParent = useMemo(() => {
const m = new Map<string, typeof clientList>();
for (const c of clientList) {
if (c.parentId) {
const arr = m.get(c.parentId) ?? [];
arr.push(c);
m.set(c.parentId, arr);
}
}
return m;
}, [clientList]);
const createClientMutation = trpc.clients.create.useMutation({
onSuccess: () => void utils.clients.list.invalidate(),
});
const createProjectMutation = trpc.projects.create.useMutation({
onSuccess: () => void utils.projects.listAll.invalidate(),
});
const createTask = trpc.tasks.create.useMutation({
onSuccess: () => {
void utils.tasks.list.invalidate();
resetAndClose();
},
});
function resetAndClose() {
setTitle('');
setDescription('');
setPriority('medium');
setStatus(defaultStatus ?? 'todo');
setDueDate(undefined);
setDueTime('');
setProjectId(defaultProjectId ?? '');
setAssignees([]);
setAssigneeInput('');
setAssigneePopoverOpen(false);
resetProjectCreation();
onOpenChange(false);
}
function resetProjectCreation() {
setCreatingProject(false);
setNewProjectName('');
setNewProjectClientId(NO_CLIENT);
setNewProjectSubClientId(NO_CLIENT);
setCreatingClient(false);
setNewClientName('');
setCreatingSubClient(false);
setNewSubClientName('');
}
function addNewAssignee() {
const name = assigneeInput.trim();
if (!name || assignees.includes(name)) return;
setAssignees((prev) => [...prev, name]);
setAssigneeInput('');
}
function toggleAssignee(name: string) {
setAssignees((prev) =>
prev.includes(name) ? prev.filter((a) => a !== name) : [...prev, name],
);
}
function removeAssignee(name: string) {
setAssignees((prev) => prev.filter((a) => a !== name));
}
async function handleCreateInlineProject(): Promise<string | undefined> {
let resolvedClientId: string | undefined;
if (creatingClient && newClientName.trim()) {
const r = await createClientMutation.mutateAsync({ name: newClientName.trim() });
resolvedClientId = r.id;
if (creatingSubClient && newSubClientName.trim()) {
const sr = await createClientMutation.mutateAsync({
name: newSubClientName.trim(),
parentId: resolvedClientId,
});
resolvedClientId = sr.id;
}
} else if (newProjectClientId !== NO_CLIENT) {
if (creatingSubClient && newSubClientName.trim()) {
const sr = await createClientMutation.mutateAsync({
name: newSubClientName.trim(),
parentId: newProjectClientId,
});
resolvedClientId = sr.id;
} else if (newProjectSubClientId !== NO_CLIENT) {
resolvedClientId = newProjectSubClientId;
} else {
resolvedClientId = newProjectClientId;
}
}
const r = await createProjectMutation.mutateAsync({
name: newProjectName.trim(),
clientId: resolvedClientId,
});
return r.id;
}
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
if (!title.trim()) return;
// Resolve dueDate + optional time
let resolvedDueDate: number | undefined;
if (dueDate) {
const d = new Date(dueDate);
if (dueTime) {
const parts = dueTime.split(':');
const h = parseInt(parts[0] ?? '0', 10);
const m = parseInt(parts[1] ?? '0', 10);
d.setHours(h, m, 0, 0);
}
resolvedDueDate = d.getTime();
}
// If creating a new project inline, do that first
let resolvedProjectId = projectId || undefined;
if (creatingProject && newProjectName.trim()) {
resolvedProjectId = await handleCreateInlineProject();
}
createTask.mutate({
title: title.trim(),
description: description.trim() || undefined,
priority,
status,
dueDate: resolvedDueDate,
projectId: resolvedProjectId,
assignees: assignees.length ? assignees : undefined,
});
}
const isSubmitting =
createTask.isPending ||
createClientMutation.isPending ||
createProjectMutation.isPending;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[560px]" aria-describedby={undefined}>
<DialogHeader>
<DialogTitle>New Task</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
{/* Title */}
<Input
placeholder="Task title"
value={title}
onChange={(e) => setTitle(e.target.value)}
required
autoFocus
/>
{/* Description */}
<Textarea
placeholder="Description (optional)"
value={description}
onChange={(e) => setDescription(e.target.value)}
className="min-h-20"
/>
{/* Priority */}
<Select value={priority} onValueChange={setPriority}>
<SelectTrigger className="w-full">
<SelectValue placeholder="Priority" />
</SelectTrigger>
<SelectContent>
<SelectItem value="high">High</SelectItem>
<SelectItem value="medium">Medium</SelectItem>
<SelectItem value="low">Low</SelectItem>
</SelectContent>
</Select>
{/* Status */}
<Select value={status} onValueChange={setStatus}>
<SelectTrigger className="w-full">
<SelectValue placeholder="Status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="todo">To Do</SelectItem>
<SelectItem value="in_progress">In Progress</SelectItem>
<SelectItem value="done">Completed</SelectItem>
</SelectContent>
</Select>
{/* Due Date + Time */}
<div className="flex flex-col gap-2">
<Popover>
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
className={cn(
'justify-start text-left font-normal',
!dueDate && 'text-muted-foreground',
)}
>
<CalendarIcon className="mr-2 h-4 w-4" />
{dueDate
? format(dueDate, dueTime ? 'PPP' : 'PPP')
: 'Pick a due date'}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={dueDate}
onSelect={setDueDate}
/>
<div className="border-t px-3 py-2">
<label className="text-xs text-muted-foreground mb-1 block">Time (optional)</label>
<Input
type="time"
value={dueTime}
onChange={(e) => setDueTime(e.target.value)}
className="h-8 text-sm"
/>
</div>
</PopoverContent>
</Popover>
{dueDate && dueTime && (
<p className="text-xs text-muted-foreground pl-1">
Due: {format(dueDate, 'PPP')} at {dueTime}
</p>
)}
</div>
{/* Project */}
{!creatingProject ? (
<div className="flex items-center gap-2">
<Select
value={projectId || 'none'}
onValueChange={(v) => setProjectId(v === 'none' ? '' : v)}
>
<SelectTrigger className="flex-1">
<SelectValue placeholder="Project (optional)" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">No project</SelectItem>
{projectsList?.map((p) => (
<SelectItem key={p.id} value={p.id}>
{p.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setCreatingProject(true)}
>
<Plus className="size-3.5 mr-1" />New
</Button>
</div>
) : (
<div className="flex flex-col gap-3 rounded-md border p-3">
<div className="flex items-center justify-between">
<span className="text-sm font-medium">New Project</span>
<Button
type="button"
variant="ghost"
size="sm"
onClick={resetProjectCreation}
>
Cancel
</Button>
</div>
{/* Project name */}
<Input
placeholder="Project name"
value={newProjectName}
onChange={(e) => setNewProjectName(e.target.value)}
autoFocus
/>
{/* Client selection */}
<div className="flex flex-col gap-1.5">
<label className="text-xs text-muted-foreground">
Client <span className="opacity-60">(optional)</span>
</label>
{creatingClient ? (
<div className="flex items-center gap-2">
<Input
placeholder="New client name"
value={newClientName}
onChange={(e) => setNewClientName(e.target.value)}
className="flex-1"
/>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => {
setCreatingClient(false);
setNewClientName('');
setCreatingSubClient(false);
setNewSubClientName('');
}}
>
Cancel
</Button>
</div>
) : (
<div className="flex items-center gap-2">
<Select
value={newProjectClientId}
onValueChange={(v) => {
setNewProjectClientId(v);
setNewProjectSubClientId(NO_CLIENT);
setCreatingSubClient(false);
setNewSubClientName('');
}}
>
<SelectTrigger className="flex-1">
<SelectValue placeholder="Select a client" />
</SelectTrigger>
<SelectContent>
<SelectItem value={NO_CLIENT}>None (Internal)</SelectItem>
{topLevelClients.map((c) => (
<SelectItem key={c.id} value={c.id}>{c.name}</SelectItem>
))}
</SelectContent>
</Select>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setCreatingClient(true)}
>
<Plus className="size-3.5 mr-1" />New
</Button>
</div>
)}
</div>
{/* Sub-client selection — only when a client is selected or being created */}
{(newProjectClientId !== NO_CLIENT || (creatingClient && newClientName.trim())) && (
<div className="flex flex-col gap-1.5">
<label className="text-xs text-muted-foreground">
Sub-client <span className="opacity-60">(optional)</span>
</label>
{creatingSubClient ? (
<div className="flex items-center gap-2">
<Input
placeholder="New sub-client name"
value={newSubClientName}
onChange={(e) => setNewSubClientName(e.target.value)}
className="flex-1"
/>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => {
setCreatingSubClient(false);
setNewSubClientName('');
}}
>
Cancel
</Button>
</div>
) : creatingClient ? (
<Button
type="button"
variant="outline"
size="sm"
className="w-fit"
onClick={() => setCreatingSubClient(true)}
>
<Plus className="size-3.5 mr-1" />New Sub-client
</Button>
) : (
<div className="flex items-center gap-2">
<Select
value={newProjectSubClientId}
onValueChange={setNewProjectSubClientId}
>
<SelectTrigger className="flex-1">
<SelectValue placeholder="Select a sub-client" />
</SelectTrigger>
<SelectContent>
<SelectItem value={NO_CLIENT}>None</SelectItem>
{(subClientsByParent.get(newProjectClientId) ?? []).map((sc) => (
<SelectItem key={sc.id} value={sc.id}>{sc.name}</SelectItem>
))}
</SelectContent>
</Select>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setCreatingSubClient(true)}
>
<Plus className="size-3.5 mr-1" />New
</Button>
</div>
)}
</div>
)}
</div>
)}
{/* Assignees */}
<div className="flex flex-col gap-2">
{/* Selected assignee badges */}
{assignees.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{assignees.map((name) => (
<Badge key={name} variant="secondary" className="gap-1 pr-1">
{name}
<button
type="button"
onClick={() => removeAssignee(name)}
className="ml-0.5 rounded-sm opacity-70 hover:opacity-100"
>
<X className="h-3 w-3" />
</button>
</Badge>
))}
</div>
)}
{/* Assignee picker popover */}
<Popover open={assigneePopoverOpen} onOpenChange={setAssigneePopoverOpen}>
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
className={cn(
'justify-start font-normal',
assignees.length === 0 && 'text-muted-foreground',
)}
>
<UserPlus className="mr-2 h-4 w-4" />
{assignees.length > 0
? `${assignees.length} assignee${assignees.length > 1 ? 's' : ''}`
: 'Add assignees'}
</Button>
</PopoverTrigger>
<PopoverContent className="w-64 p-2" align="start">
{/* Known assignees list */}
{knownAssignees.length > 0 && (
<div className="max-h-36 overflow-y-auto flex flex-col gap-0.5 mb-2">
{knownAssignees.map((name) => (
<Button
key={name}
type="button"
variant="ghost"
size="sm"
className="justify-start h-8 px-2"
onClick={() => toggleAssignee(name)}
>
{assignees.includes(name) ? (
<Check className="h-3 w-3 mr-2 text-primary shrink-0" />
) : (
<span className="w-5 shrink-0" />
)}
<span className="truncate">{name}</span>
</Button>
))}
</div>
)}
{knownAssignees.length === 0 && (
<p className="text-xs text-muted-foreground px-2 py-1 mb-2">No existing assignees</p>
)}
<Separator className="mb-2" />
{/* Add new assignee */}
<div className="flex gap-2">
<Input
placeholder="New name…"
value={assigneeInput}
onChange={(e) => setAssigneeInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
addNewAssignee();
}
}}
className="h-8 text-sm"
/>
<Button
type="button"
size="sm"
onClick={addNewAssignee}
disabled={!assigneeInput.trim()}
>
Add
</Button>
</div>
</PopoverContent>
</Popover>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={resetAndClose}>
Cancel
</Button>
<Button type="submit" disabled={!title.trim() || isSubmitting}>
{isSubmitting ? 'Creating…' : 'Create Task'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,29 @@
import { ArrowUp, ArrowRight, ArrowDown } from 'lucide-react';
export function PriorityBadge({ priority }: { priority: string | null }) {
switch (priority) {
case 'high':
return (
<span className="inline-flex items-center gap-1 text-xs">
<ArrowUp className="h-3 w-3" />
High
</span>
);
case 'medium':
return (
<span className="inline-flex items-center gap-1 text-xs">
<ArrowRight className="h-3 w-3" />
Medium
</span>
);
case 'low':
return (
<span className="inline-flex items-center gap-1 text-xs">
<ArrowDown className="h-3 w-3" />
Low
</span>
);
default:
return null;
}
}

View File

@@ -0,0 +1,153 @@
import { Calendar, User, Pencil, Trash2 } from 'lucide-react';
import { Checkbox } from '@/components/ui/checkbox';
import { Badge } from '@/components/ui/badge';
import {
Breadcrumb,
BreadcrumbList,
BreadcrumbItem,
BreadcrumbSeparator,
} from '@/components/ui/breadcrumb';
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuTrigger,
} from '@/components/ui/context-menu';
import { PriorityBadge } from './PriorityBadge';
export type TaskItem = {
id: string;
projectId: string | null;
title: string;
description: string | null;
status: string | null;
priority: string | null;
assignee: string | null;
dueDate: number | null;
projectName: string | null;
clientName: string | null;
subClientName: string | null;
};
export function parseAssignees(raw: string | null): string[] {
if (!raw) return [];
try {
const parsed = JSON.parse(raw) as unknown;
if (Array.isArray(parsed)) return parsed.filter((n): n is string => typeof n === 'string');
} catch { /* plain string fallback */ }
return [raw];
}
function formatDueDate(timestamp: number): string {
const d = new Date(timestamp);
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
return `Due ${months[d.getMonth()]} ${d.getDate()}`;
}
export function TaskRow({
task,
onToggle,
onEdit,
onDelete,
}: {
task: TaskItem;
onToggle: (id: string, status: string | null) => void;
onEdit?: (task: TaskItem) => void;
onDelete?: (id: string) => void;
}) {
const isDone = task.status === 'done';
const checkboxState: boolean | 'indeterminate' =
task.status === 'done' ? true :
task.status === 'in_progress' ? 'indeterminate' : false;
const breadcrumb: string[] = [];
if (task.clientName) breadcrumb.push(task.clientName);
if (task.subClientName) breadcrumb.push(task.subClientName);
if (task.projectName) breadcrumb.push(task.projectName);
const hasMetadata =
task.priority ||
task.dueDate ||
breadcrumb.length > 0 ||
task.assignee;
return (
<ContextMenu>
<ContextMenuTrigger asChild>
<div
className={`flex flex-col gap-1.5 px-4 py-3 rounded-md border cursor-default select-none ${
isDone ? 'bg-green-50 border-green-200' : 'bg-white border-border'
}`}
>
{/* Row 1: checkbox + title + description */}
<div className="flex items-start gap-3">
<Checkbox
checked={checkboxState}
onCheckedChange={() => onToggle(task.id, task.status)}
className="mt-0.5 shrink-0"
/>
<div className="flex-1 min-w-0">
<div className={`text-sm font-semibold ${isDone ? 'line-through text-muted-foreground' : ''}`}>
{task.title}
</div>
{task.description && (
<div className="text-sm text-muted-foreground truncate">
{task.description}
</div>
)}
</div>
</div>
{/* Row 2: metadata, indented to align with title text */}
{hasMetadata && (
<div className="flex flex-wrap items-center gap-2 pl-7">
<PriorityBadge priority={task.priority} />
{task.dueDate && (
<Badge variant="outline" className="text-xs gap-1 shrink-0">
<Calendar className="h-3 w-3" />
{formatDueDate(task.dueDate)}
</Badge>
)}
{breadcrumb.length > 0 && (
<Breadcrumb className="shrink-0">
<BreadcrumbList>
{breadcrumb.map((part, i) => (
<BreadcrumbItem key={i}>
{i > 0 && <BreadcrumbSeparator />}
<span className="text-xs">{part}</span>
</BreadcrumbItem>
))}
</BreadcrumbList>
</Breadcrumb>
)}
{task.assignee && (
<div className="flex items-center gap-1 text-xs text-muted-foreground shrink-0">
<User className="h-3 w-3" />
{parseAssignees(task.assignee).join(', ')}
</div>
)}
</div>
)}
</div>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem onSelect={() => onEdit?.(task)}>
<Pencil className="h-4 w-4 mr-2" />
Edit Task
</ContextMenuItem>
<ContextMenuItem
onSelect={() => onDelete?.(task.id)}
className="text-destructive focus:text-destructive"
>
<Trash2 className="h-4 w-4 mr-2" />
Delete Task
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
);
}

View File

@@ -0,0 +1,135 @@
import { useState } from 'react';
import { format } from 'date-fns';
import { Calendar as CalendarIcon } from 'lucide-react';
import { trpc } from '@/lib/trpc';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Calendar } from '@/components/ui/calendar';
import { cn } from '@/lib/utils';
interface AddCheckpointDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
defaultProjectId?: string;
}
export function AddCheckpointDialog({ open, onOpenChange, defaultProjectId }: AddCheckpointDialogProps) {
const [title, setTitle] = useState('');
const [date, setDate] = useState<Date | undefined>();
const [projectId, setProjectId] = useState(defaultProjectId ?? '');
const showProjectSelect = !defaultProjectId;
const { data: projectsList } = trpc.projects.listAll.useQuery(undefined, {
enabled: showProjectSelect,
});
const utils = trpc.useUtils();
const createCheckpoint = trpc.checkpoints.create.useMutation({
onSuccess: () => {
void utils.checkpoints.list.invalidate();
resetAndClose();
},
});
function resetAndClose() {
setTitle('');
setDate(undefined);
setProjectId(defaultProjectId ?? '');
onOpenChange(false);
}
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
const pid = defaultProjectId || projectId;
if (!title.trim() || !date || !pid) return;
createCheckpoint.mutate({
title: title.trim(),
date: date.getTime(),
projectId: pid,
});
}
const canSubmit = title.trim() && date && (defaultProjectId || projectId);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[400px]">
<DialogHeader>
<DialogTitle>Add Checkpoint</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
<Input
placeholder="Checkpoint title"
value={title}
onChange={(e) => setTitle(e.target.value)}
required
autoFocus
/>
<Popover>
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
className={cn(
'justify-start text-left font-normal',
!date && 'text-muted-foreground',
)}
>
<CalendarIcon className="mr-2 h-4 w-4" />
{date ? format(date, 'PPP') : 'Pick a date'}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={date}
onSelect={setDate}
/>
</PopoverContent>
</Popover>
{showProjectSelect && (
<Select value={projectId} onValueChange={setProjectId}>
<SelectTrigger>
<SelectValue placeholder="Select project (required)" />
</SelectTrigger>
<SelectContent>
{projectsList?.map((p) => (
<SelectItem key={p.id} value={p.id}>
{p.name}
</SelectItem>
))}
</SelectContent>
</Select>
)}
<DialogFooter>
<Button type="button" variant="outline" onClick={resetAndClose}>
Cancel
</Button>
<Button type="submit" disabled={!canSubmit || createCheckpoint.isPending}>
Add Checkpoint
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,228 @@
import { useRef, useState, useEffect, useCallback } from 'react';
import { format } from 'date-fns';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Button } from '@/components/ui/button';
export interface GanttCheckpoint {
id: string;
title: string;
date: number; // unix timestamp ms
projectId: string;
projectName?: string;
isAiSuggested: number;
isApproved: number;
}
interface GanttChartProps {
checkpoints: GanttCheckpoint[];
startDate: Date;
endDate: Date;
onDelete?: (id: string) => void;
}
const HEADER_HEIGHT = 30;
const BASELINE_Y = 70;
const SVG_HEIGHT = 100;
const DOT_RADIUS = 7;
const PADDING_X = 40;
function getMonthsBetween(start: Date, end: Date): Date[] {
const months: Date[] = [];
const current = new Date(start.getFullYear(), start.getMonth(), 1);
while (current <= end) {
months.push(new Date(current));
current.setMonth(current.getMonth() + 1);
}
return months;
}
function dateToX(date: Date, start: Date, end: Date, width: number): number {
const totalMs = end.getTime() - start.getTime();
if (totalMs <= 0) return PADDING_X;
const ratio = (date.getTime() - start.getTime()) / totalMs;
return PADDING_X + ratio * (width - PADDING_X * 2);
}
export function GanttChart({ checkpoints, startDate, endDate, onDelete }: GanttChartProps) {
const containerRef = useRef<HTMLDivElement>(null);
const [width, setWidth] = useState(600);
useEffect(() => {
const el = containerRef.current;
if (!el) return;
const observer = new ResizeObserver((entries) => {
const entry = entries[0];
if (entry) {
setWidth(entry.contentRect.width);
}
});
observer.observe(el);
return () => observer.disconnect();
}, []);
const months = getMonthsBetween(startDate, endDate);
const todayX = dateToX(new Date(), startDate, endDate, width);
return (
<div ref={containerRef} className="w-full overflow-hidden">
<svg width={width} height={SVG_HEIGHT} className="select-none">
{/* Month labels */}
{months.map((month) => {
const x = dateToX(month, startDate, endDate, width);
return (
<g key={month.toISOString()}>
<text
x={x}
y={HEADER_HEIGHT - 8}
textAnchor="middle"
className="fill-muted-foreground"
fontSize={12}
fontFamily="Geist, sans-serif"
>
{format(month, 'MMM yyyy')}
</text>
<line
x1={x}
y1={HEADER_HEIGHT}
x2={x}
y2={BASELINE_Y + 10}
stroke="#e5e5e5"
strokeWidth={1}
strokeDasharray="4 4"
/>
</g>
);
})}
{/* Baseline */}
<line
x1={PADDING_X}
y1={BASELINE_Y}
x2={width - PADDING_X}
y2={BASELINE_Y}
stroke="#d4d4d4"
strokeWidth={2}
/>
{/* Today marker */}
{todayX >= PADDING_X && todayX <= width - PADDING_X && (
<g>
<line
x1={todayX}
y1={HEADER_HEIGHT}
x2={todayX}
y2={BASELINE_Y + 10}
stroke="#ef4444"
strokeWidth={1.5}
/>
<text
x={todayX}
y={BASELINE_Y + 22}
textAnchor="middle"
fill="#ef4444"
fontSize={10}
fontFamily="Geist, sans-serif"
>
Today
</text>
</g>
)}
{/* Checkpoint dots rendered as foreignObject for Popover */}
{checkpoints.map((cp) => {
const cx = dateToX(new Date(cp.date), startDate, endDate, width);
return (
<CheckpointDot
key={cp.id}
checkpoint={cp}
cx={cx}
onDelete={onDelete}
/>
);
})}
</svg>
</div>
);
}
function CheckpointDot({
checkpoint,
cx,
onDelete,
}: {
checkpoint: GanttCheckpoint;
cx: number;
onDelete?: (id: string) => void;
}) {
const [open, setOpen] = useState(false);
const handleDelete = useCallback(() => {
onDelete?.(checkpoint.id);
setOpen(false);
}, [onDelete, checkpoint.id]);
// Determine dot style
// isApproved=0 (pending AI suggestion) → dashed outline
// isApproved=1 + date in past → green (#16a34a) = completed
// isApproved=1 + date in future → dark (#171717) = todo
const isPending = checkpoint.isApproved === 0;
const isPast = checkpoint.date < Date.now();
const fill = isPending ? 'none' : (isPast ? '#16a34a' : '#171717');
const stroke = isPending ? '#737373' : 'none';
const strokeDasharray = isPending ? '3 2' : undefined;
return (
<foreignObject
x={cx - 16}
y={BASELINE_Y - 16}
width={32}
height={32}
className="overflow-visible"
>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button
className="flex items-center justify-center w-full h-full focus:outline-none cursor-pointer"
type="button"
>
<svg width={DOT_RADIUS * 2 + 2} height={DOT_RADIUS * 2 + 2}>
<circle
cx={DOT_RADIUS + 1}
cy={DOT_RADIUS + 1}
r={DOT_RADIUS}
fill={fill}
stroke={stroke || '#171717'}
strokeWidth={isPending ? 1.5 : 0}
strokeDasharray={strokeDasharray}
/>
</svg>
</button>
</PopoverTrigger>
<PopoverContent className="w-60 p-3" side="top">
<div className="flex flex-col gap-2">
<div className="font-semibold text-sm">{checkpoint.title}</div>
<div className="text-xs text-muted-foreground">
{format(new Date(checkpoint.date), 'PPP')}
</div>
{checkpoint.projectName && (
<div className="text-xs text-muted-foreground">
Project: {checkpoint.projectName}
</div>
)}
{onDelete && (
<Button
variant="destructive"
size="sm"
onClick={handleDelete}
className="mt-1"
>
Delete
</Button>
)}
</div>
</PopoverContent>
</Popover>
</foreignObject>
);
}

View File

@@ -0,0 +1,48 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "radix-ui"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex items-center justify-center rounded-full border border-transparent px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
secondary:
"bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive:
"bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border-border text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
ghost: "[a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
link: "text-primary underline-offset-4 [a&]:hover:underline",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant = "default",
asChild = false,
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot.Root : "span"
return (
<Comp
data-slot="badge"
data-variant={variant}
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
)
}
export { Badge, badgeVariants }

View File

@@ -0,0 +1,109 @@
import * as React from "react"
import { ChevronRight, MoreHorizontal } from "lucide-react"
import { Slot } from "radix-ui"
import { cn } from "@/lib/utils"
function Breadcrumb({ ...props }: React.ComponentProps<"nav">) {
return <nav aria-label="breadcrumb" data-slot="breadcrumb" {...props} />
}
function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
return (
<ol
data-slot="breadcrumb-list"
className={cn(
"text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm break-words sm:gap-2.5",
className
)}
{...props}
/>
)
}
function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
return (
<li
data-slot="breadcrumb-item"
className={cn("inline-flex items-center gap-1.5", className)}
{...props}
/>
)
}
function BreadcrumbLink({
asChild,
className,
...props
}: React.ComponentProps<"a"> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot.Root : "a"
return (
<Comp
data-slot="breadcrumb-link"
className={cn("hover:text-foreground transition-colors", className)}
{...props}
/>
)
}
function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="breadcrumb-page"
role="link"
aria-disabled="true"
aria-current="page"
className={cn("text-foreground font-normal", className)}
{...props}
/>
)
}
function BreadcrumbSeparator({
children,
className,
...props
}: React.ComponentProps<"li">) {
return (
<li
data-slot="breadcrumb-separator"
role="presentation"
aria-hidden="true"
className={cn("[&>svg]:size-3.5", className)}
{...props}
>
{children ?? <ChevronRight />}
</li>
)
}
function BreadcrumbEllipsis({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="breadcrumb-ellipsis"
role="presentation"
aria-hidden="true"
className={cn("flex size-9 items-center justify-center", className)}
{...props}
>
<MoreHorizontal className="size-4" />
<span className="sr-only">More</span>
</span>
)
}
export {
Breadcrumb,
BreadcrumbList,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbPage,
BreadcrumbSeparator,
BreadcrumbEllipsis,
}

View File

@@ -0,0 +1,220 @@
"use client"
import * as React from "react"
import {
ChevronDownIcon,
ChevronLeftIcon,
ChevronRightIcon,
} from "lucide-react"
import {
DayPicker,
getDefaultClassNames,
type DayButton,
} from "react-day-picker"
import { cn } from "@/lib/utils"
import { Button, buttonVariants } from "@/components/ui/button"
function Calendar({
className,
classNames,
showOutsideDays = true,
captionLayout = "label",
buttonVariant = "ghost",
formatters,
components,
...props
}: React.ComponentProps<typeof DayPicker> & {
buttonVariant?: React.ComponentProps<typeof Button>["variant"]
}) {
const defaultClassNames = getDefaultClassNames()
return (
<DayPicker
showOutsideDays={showOutsideDays}
className={cn(
"bg-background group/calendar p-3 [--cell-size:--spacing(8)] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent",
String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
className
)}
captionLayout={captionLayout}
formatters={{
formatMonthDropdown: (date) =>
date.toLocaleString("default", { month: "short" }),
...formatters,
}}
classNames={{
root: cn("w-fit", defaultClassNames.root),
months: cn(
"flex gap-4 flex-col md:flex-row relative",
defaultClassNames.months
),
month: cn("flex flex-col w-full gap-4", defaultClassNames.month),
nav: cn(
"flex items-center gap-1 w-full absolute top-0 inset-x-0 justify-between",
defaultClassNames.nav
),
button_previous: cn(
buttonVariants({ variant: buttonVariant }),
"size-(--cell-size) aria-disabled:opacity-50 p-0 select-none",
defaultClassNames.button_previous
),
button_next: cn(
buttonVariants({ variant: buttonVariant }),
"size-(--cell-size) aria-disabled:opacity-50 p-0 select-none",
defaultClassNames.button_next
),
month_caption: cn(
"flex items-center justify-center h-(--cell-size) w-full px-(--cell-size)",
defaultClassNames.month_caption
),
dropdowns: cn(
"w-full flex items-center text-sm font-medium justify-center h-(--cell-size) gap-1.5",
defaultClassNames.dropdowns
),
dropdown_root: cn(
"relative has-focus:border-ring border border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] rounded-md",
defaultClassNames.dropdown_root
),
dropdown: cn(
"absolute bg-popover inset-0 opacity-0",
defaultClassNames.dropdown
),
caption_label: cn(
"select-none font-medium",
captionLayout === "label"
? "text-sm"
: "rounded-md pl-2 pr-1 flex items-center gap-1 text-sm h-8 [&>svg]:text-muted-foreground [&>svg]:size-3.5",
defaultClassNames.caption_label
),
table: "w-full border-collapse",
weekdays: cn("flex", defaultClassNames.weekdays),
weekday: cn(
"text-muted-foreground rounded-md flex-1 font-normal text-[0.8rem] select-none",
defaultClassNames.weekday
),
week: cn("flex w-full mt-2", defaultClassNames.week),
week_number_header: cn(
"select-none w-(--cell-size)",
defaultClassNames.week_number_header
),
week_number: cn(
"text-[0.8rem] select-none text-muted-foreground",
defaultClassNames.week_number
),
day: cn(
"relative w-full h-full p-0 text-center [&:last-child[data-selected=true]_button]:rounded-r-md group/day aspect-square select-none",
props.showWeekNumber
? "[&:nth-child(2)[data-selected=true]_button]:rounded-l-md"
: "[&:first-child[data-selected=true]_button]:rounded-l-md",
defaultClassNames.day
),
range_start: cn(
"rounded-l-md bg-accent",
defaultClassNames.range_start
),
range_middle: cn("rounded-none", defaultClassNames.range_middle),
range_end: cn("rounded-r-md bg-accent", defaultClassNames.range_end),
today: cn(
"bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none",
defaultClassNames.today
),
outside: cn(
"text-muted-foreground aria-selected:text-muted-foreground",
defaultClassNames.outside
),
disabled: cn(
"text-muted-foreground opacity-50",
defaultClassNames.disabled
),
hidden: cn("invisible", defaultClassNames.hidden),
...classNames,
}}
components={{
Root: ({ className, rootRef, ...props }) => {
return (
<div
data-slot="calendar"
ref={rootRef}
className={cn(className)}
{...props}
/>
)
},
Chevron: ({ className, orientation, ...props }) => {
if (orientation === "left") {
return (
<ChevronLeftIcon className={cn("size-4", className)} {...props} />
)
}
if (orientation === "right") {
return (
<ChevronRightIcon
className={cn("size-4", className)}
{...props}
/>
)
}
return (
<ChevronDownIcon className={cn("size-4", className)} {...props} />
)
},
DayButton: CalendarDayButton,
WeekNumber: ({ children, ...props }) => {
return (
<td {...props}>
<div className="flex size-(--cell-size) items-center justify-center text-center">
{children}
</div>
</td>
)
},
...components,
}}
{...props}
/>
)
}
function CalendarDayButton({
className,
day,
modifiers,
...props
}: React.ComponentProps<typeof DayButton>) {
const defaultClassNames = getDefaultClassNames()
const ref = React.useRef<HTMLButtonElement>(null)
React.useEffect(() => {
if (modifiers.focused) ref.current?.focus()
}, [modifiers.focused])
return (
<Button
ref={ref}
variant="ghost"
size="icon"
data-day={day.date.toLocaleDateString()}
data-selected-single={
modifiers.selected &&
!modifiers.range_start &&
!modifiers.range_end &&
!modifiers.range_middle
}
data-range-start={modifiers.range_start}
data-range-end={modifiers.range_end}
data-range-middle={modifiers.range_middle}
className={cn(
"data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 dark:hover:text-accent-foreground flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] data-[range-end=true]:rounded-md data-[range-end=true]:rounded-r-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md data-[range-start=true]:rounded-l-md [&>span]:text-xs [&>span]:opacity-70",
defaultClassNames.day,
className
)}
{...props}
/>
)
}
export { Calendar, CalendarDayButton }

View File

@@ -0,0 +1,92 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold", className)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-6", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}

View File

@@ -0,0 +1,33 @@
"use client"
import * as React from "react"
import { CheckIcon, MinusIcon } from "lucide-react"
import { Checkbox as CheckboxPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Checkbox({
className,
...props
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
const isIndeterminate = props.checked === 'indeterminate';
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary data-[state=indeterminate]:bg-primary data-[state=indeterminate]:text-primary-foreground data-[state=indeterminate]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="grid place-content-center text-current transition-none"
>
{isIndeterminate ? <MinusIcon className="size-3.5" /> : <CheckIcon className="size-3.5" />}
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
)
}
export { Checkbox }

View File

@@ -0,0 +1,168 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea"
function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="input-group"
role="group"
className={cn(
"group/input-group border-input dark:bg-input/30 relative flex w-full items-center rounded-md border shadow-xs transition-[color,box-shadow] outline-none",
"h-9 min-w-0 has-[>textarea]:h-auto",
// Variants based on alignment.
"has-[>[data-align=inline-start]]:[&>input]:pl-2",
"has-[>[data-align=inline-end]]:[&>input]:pr-2",
"has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>[data-align=block-start]]:[&>input]:pb-3",
"has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-end]]:[&>input]:pt-3",
// Focus state.
"has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot=input-group-control]:focus-visible]:ring-[3px]",
// Error state.
"has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[[data-slot][aria-invalid=true]]:border-destructive dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40",
className
)}
{...props}
/>
)
}
const inputGroupAddonVariants = cva(
"text-muted-foreground flex h-auto cursor-text items-center justify-center gap-2 py-1.5 text-sm font-medium select-none [&>svg:not([class*='size-'])]:size-4 [&>kbd]:rounded-[calc(var(--radius)-5px)] group-data-[disabled=true]/input-group:opacity-50",
{
variants: {
align: {
"inline-start":
"order-first pl-3 has-[>button]:ml-[-0.45rem] has-[>kbd]:ml-[-0.35rem]",
"inline-end":
"order-last pr-3 has-[>button]:mr-[-0.45rem] has-[>kbd]:mr-[-0.35rem]",
"block-start":
"order-first w-full justify-start px-3 pt-3 [.border-b]:pb-3 group-has-[>input]/input-group:pt-2.5",
"block-end":
"order-last w-full justify-start px-3 pb-3 [.border-t]:pt-3 group-has-[>input]/input-group:pb-2.5",
},
},
defaultVariants: {
align: "inline-start",
},
}
)
function InputGroupAddon({
className,
align = "inline-start",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof inputGroupAddonVariants>) {
return (
<div
role="group"
data-slot="input-group-addon"
data-align={align}
className={cn(inputGroupAddonVariants({ align }), className)}
onClick={(e) => {
if ((e.target as HTMLElement).closest("button")) {
return
}
e.currentTarget.parentElement?.querySelector("input")?.focus()
}}
{...props}
/>
)
}
const inputGroupButtonVariants = cva(
"text-sm shadow-none flex gap-2 items-center",
{
variants: {
size: {
xs: "h-6 gap-1 px-2 rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-3.5 has-[>svg]:px-2",
sm: "h-8 px-2.5 gap-1.5 rounded-md has-[>svg]:px-2.5",
"icon-xs":
"size-6 rounded-[calc(var(--radius)-5px)] p-0 has-[>svg]:p-0",
"icon-sm": "size-8 p-0 has-[>svg]:p-0",
},
},
defaultVariants: {
size: "xs",
},
}
)
function InputGroupButton({
className,
type = "button",
variant = "ghost",
size = "xs",
...props
}: Omit<React.ComponentProps<typeof Button>, "size"> &
VariantProps<typeof inputGroupButtonVariants>) {
return (
<Button
type={type}
data-size={size}
variant={variant}
className={cn(inputGroupButtonVariants({ size }), className)}
{...props}
/>
)
}
function InputGroupText({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
className={cn(
"text-muted-foreground flex items-center gap-2 text-sm [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function InputGroupInput({
className,
...props
}: React.ComponentProps<"input">) {
return (
<Input
data-slot="input-group-control"
className={cn(
"flex-1 rounded-none border-0 bg-transparent shadow-none focus-visible:ring-0 dark:bg-transparent",
className
)}
{...props}
/>
)
}
function InputGroupTextarea({
className,
...props
}: React.ComponentProps<"textarea">) {
return (
<Textarea
data-slot="input-group-control"
className={cn(
"flex-1 resize-none rounded-none border-0 bg-transparent py-3 shadow-none focus-visible:ring-0 dark:bg-transparent",
className
)}
{...props}
/>
)
}
export {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupText,
InputGroupInput,
InputGroupTextarea,
}

View File

@@ -0,0 +1,193 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "radix-ui"
import { cn } from "@/lib/utils"
import { Separator } from "@/components/ui/separator"
function ItemGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
role="list"
data-slot="item-group"
className={cn("group/item-group flex flex-col", className)}
{...props}
/>
)
}
function ItemSeparator({
className,
...props
}: React.ComponentProps<typeof Separator>) {
return (
<Separator
data-slot="item-separator"
orientation="horizontal"
className={cn("my-0", className)}
{...props}
/>
)
}
const itemVariants = cva(
"group/item flex items-center border border-transparent text-sm rounded-md transition-colors [a]:hover:bg-accent/50 [a]:transition-colors duration-100 flex-wrap outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
{
variants: {
variant: {
default: "bg-transparent",
outline: "border-border",
muted: "bg-muted/50",
},
size: {
default: "p-4 gap-4 ",
sm: "py-3 px-4 gap-2.5",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Item({
className,
variant = "default",
size = "default",
asChild = false,
...props
}: React.ComponentProps<"div"> &
VariantProps<typeof itemVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot.Root : "div"
return (
<Comp
data-slot="item"
data-variant={variant}
data-size={size}
className={cn(itemVariants({ variant, size, className }))}
{...props}
/>
)
}
const itemMediaVariants = cva(
"flex shrink-0 items-center justify-center gap-2 group-has-[[data-slot=item-description]]/item:self-start [&_svg]:pointer-events-none group-has-[[data-slot=item-description]]/item:translate-y-0.5",
{
variants: {
variant: {
default: "bg-transparent",
icon: "size-8 [&_svg:not([class*='size-'])]:size-4",
image:
"size-10 rounded-sm overflow-hidden [&_img]:size-full [&_img]:object-cover",
},
},
defaultVariants: {
variant: "default",
},
}
)
function ItemMedia({
className,
variant = "default",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof itemMediaVariants>) {
return (
<div
data-slot="item-media"
data-variant={variant}
className={cn(itemMediaVariants({ variant, className }))}
{...props}
/>
)
}
function ItemContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="item-content"
className={cn(
"flex flex-1 flex-col gap-1 [&+[data-slot=item-content]]:flex-none",
className
)}
{...props}
/>
)
}
function ItemTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="item-title"
className={cn(
"flex w-fit items-center gap-2 text-sm leading-snug font-medium",
className
)}
{...props}
/>
)
}
function ItemDescription({ className, ...props }: React.ComponentProps<"p">) {
return (
<p
data-slot="item-description"
className={cn(
"text-muted-foreground line-clamp-2 text-sm leading-normal font-normal text-balance",
"[&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4",
className
)}
{...props}
/>
)
}
function ItemActions({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="item-actions"
className={cn("flex items-center gap-2", className)}
{...props}
/>
)
}
function ItemHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="item-header"
className={cn(
"flex basis-full items-center justify-between gap-2",
className
)}
{...props}
/>
)
}
function ItemFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="item-footer"
className={cn(
"flex basis-full items-center justify-between gap-2",
className
)}
{...props}
/>
)
}
export {
Item,
ItemMedia,
ItemContent,
ItemActions,
ItemGroup,
ItemSeparator,
ItemTitle,
ItemDescription,
ItemHeader,
ItemFooter,
}

View File

@@ -0,0 +1,87 @@
import * as React from "react"
import { Popover as PopoverPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Popover({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
return <PopoverPrimitive.Root data-slot="popover" {...props} />
}
function PopoverTrigger({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
}
function PopoverContent({
className,
align = "center",
sideOffset = 4,
...props
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
data-slot="popover-content"
align={align}
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
)
}
function PopoverAnchor({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
}
function PopoverHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="popover-header"
className={cn("flex flex-col gap-1 text-sm", className)}
{...props}
/>
)
}
function PopoverTitle({ className, ...props }: React.ComponentProps<"h2">) {
return (
<div
data-slot="popover-title"
className={cn("font-medium", className)}
{...props}
/>
)
}
function PopoverDescription({
className,
...props
}: React.ComponentProps<"p">) {
return (
<p
data-slot="popover-description"
className={cn("text-muted-foreground", className)}
{...props}
/>
)
}
export {
Popover,
PopoverTrigger,
PopoverContent,
PopoverAnchor,
PopoverHeader,
PopoverTitle,
PopoverDescription,
}

View File

@@ -0,0 +1,89 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Tabs as TabsPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Tabs({
className,
orientation = "horizontal",
...props
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
return (
<TabsPrimitive.Root
data-slot="tabs"
data-orientation={orientation}
orientation={orientation}
className={cn(
"group/tabs flex gap-2 data-[orientation=horizontal]:flex-col",
className
)}
{...props}
/>
)
}
const tabsListVariants = cva(
"rounded-lg p-[3px] group-data-[orientation=horizontal]/tabs:h-9 data-[variant=line]:rounded-none group/tabs-list text-muted-foreground inline-flex w-fit items-center justify-center group-data-[orientation=vertical]/tabs:h-fit group-data-[orientation=vertical]/tabs:flex-col",
{
variants: {
variant: {
default: "bg-muted",
line: "gap-1 bg-transparent",
},
},
defaultVariants: {
variant: "default",
},
}
)
function TabsList({
className,
variant = "default",
...props
}: React.ComponentProps<typeof TabsPrimitive.List> &
VariantProps<typeof tabsListVariants>) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
data-variant={variant}
className={cn(tabsListVariants({ variant }), className)}
{...props}
/>
)
}
function TabsTrigger({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
return (
<TabsPrimitive.Trigger
data-slot="tabs-trigger"
className={cn(
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring text-foreground/60 hover:text-foreground dark:text-muted-foreground dark:hover:text-foreground relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-all group-data-[orientation=vertical]/tabs:w-full group-data-[orientation=vertical]/tabs:justify-start focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 group-data-[variant=default]/tabs-list:data-[state=active]:shadow-sm group-data-[variant=line]/tabs-list:data-[state=active]:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:border-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent",
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 data-[state=active]:text-foreground",
"after:bg-foreground after:absolute after:opacity-0 after:transition-opacity group-data-[orientation=horizontal]/tabs:after:inset-x-0 group-data-[orientation=horizontal]/tabs:after:bottom-[-5px] group-data-[orientation=horizontal]/tabs:after:h-0.5 group-data-[orientation=vertical]/tabs:after:inset-y-0 group-data-[orientation=vertical]/tabs:after:-right-1 group-data-[orientation=vertical]/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-[state=active]:after:opacity-100",
className
)}
{...props}
/>
)
}
function TabsContent({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
return (
<TabsPrimitive.Content
data-slot="tabs-content"
className={cn("flex-1 outline-none", className)}
{...props}
/>
)
}
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }

View File

@@ -0,0 +1,18 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
{...props}
/>
)
}
export { Textarea }

View File

@@ -1,13 +1,228 @@
import { createFileRoute } from '@tanstack/react-router';
import { useState, useCallback, useMemo } from 'react';
import {
ClipboardCheck,
ListTodo,
Loader2,
CheckCircle2,
Plus,
Search,
} from 'lucide-react';
import { trpc } from '@/lib/trpc';
import { Item, ItemMedia, ItemContent, ItemTitle, ItemDescription } from '@/components/ui/item';
import { InputGroup, InputGroupAddon, InputGroupInput } from '@/components/ui/input-group';
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Button } from '@/components/ui/button';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Empty, EmptyHeader, EmptyMedia, EmptyTitle, EmptyDescription } from '@/components/ui/empty';
import { NewTaskDialog } from '@/components/tasks/NewTaskDialog';
import { EditTaskDialog } from '@/components/tasks/EditTaskDialog';
import { TaskRow, type TaskItem } from '@/components/tasks/TaskRow';
export const Route = createFileRoute('/tasks')({
component: TasksPage,
});
type StatusFilter = 'all' | 'todo' | 'in_progress' | 'done';
type OrderBy = 'dueDate' | 'priority' | 'createdAt';
const ORDER_LABELS: Record<OrderBy, string> = {
dueDate: 'Due Date',
priority: 'Priority',
createdAt: 'Created Date',
};
function TasksPage() {
const [search, setSearch] = useState('');
const [debouncedSearch, setDebouncedSearch] = useState('');
const [statusFilter, setStatusFilter] = useState<StatusFilter>('all');
const [orderBy, setOrderBy] = useState<OrderBy>('createdAt');
const [dialogOpen, setDialogOpen] = useState(false);
const [editTask, setEditTask] = useState<TaskItem | null>(null);
const debounceTimer = useMemo(() => ({ id: null as ReturnType<typeof setTimeout> | null }), []);
const handleSearchChange = useCallback(
(value: string) => {
setSearch(value);
if (debounceTimer.id) clearTimeout(debounceTimer.id);
debounceTimer.id = setTimeout(() => setDebouncedSearch(value), 300);
},
[debounceTimer],
);
const queryInput = useMemo(
() => ({
...(statusFilter !== 'all' ? { status: statusFilter as 'todo' | 'in_progress' | 'done' } : {}),
...(debouncedSearch.trim() ? { search: debouncedSearch.trim() } : {}),
orderBy,
}),
[statusFilter, debouncedSearch, orderBy],
);
const { data: allTasks } = trpc.tasks.list.useQuery({});
const { data: filteredTasks } = trpc.tasks.list.useQuery(queryInput);
const utils = trpc.useUtils();
const updateTask = trpc.tasks.update.useMutation({
onSuccess: () => {
void utils.tasks.list.invalidate();
},
});
const deleteTask = trpc.tasks.delete.useMutation({
onSuccess: () => {
void utils.tasks.list.invalidate();
},
});
const tasksList = filteredTasks ?? [];
// Compute stats from all tasks (unfiltered)
const stats = useMemo(() => {
const all = allTasks ?? [];
return {
total: all.length,
todo: all.filter((t) => t.status === 'todo').length,
inProgress: all.filter((t) => t.status === 'in_progress').length,
completed: all.filter((t) => t.status === 'done').length,
};
}, [allTasks]);
const handleCheckboxToggle = useCallback(
(taskId: string, currentStatus: string | null) => {
const nextStatus =
currentStatus === 'todo' ? 'in_progress' :
currentStatus === 'in_progress' ? 'done' : 'todo';
updateTask.mutate({ id: taskId, status: nextStatus });
},
[updateTask],
);
return (
<div className="flex items-center justify-center h-full text-muted-foreground text-sm">
Tasks coming in US-007
<div className="flex flex-col gap-6 p-6 max-w-[1200px] mx-auto w-full">
{/* Stat Cards */}
<div className="grid grid-cols-4 gap-4">
<Item variant="muted">
<ItemMedia variant="icon">
<ClipboardCheck />
</ItemMedia>
<ItemContent>
<ItemTitle>{stats.total}</ItemTitle>
<ItemDescription>Total Tasks</ItemDescription>
</ItemContent>
</Item>
<Item variant="muted">
<ItemMedia variant="icon">
<ListTodo />
</ItemMedia>
<ItemContent>
<ItemTitle>{stats.todo}</ItemTitle>
<ItemDescription>To Do</ItemDescription>
</ItemContent>
</Item>
<Item variant="muted" className="bg-sky-50">
<ItemMedia variant="icon">
<Loader2 />
</ItemMedia>
<ItemContent>
<ItemTitle>{stats.inProgress}</ItemTitle>
<ItemDescription>In Progress</ItemDescription>
</ItemContent>
</Item>
<Item variant="muted" className="bg-green-50">
<ItemMedia variant="icon">
<CheckCircle2 />
</ItemMedia>
<ItemContent>
<ItemTitle>{stats.completed}</ItemTitle>
<ItemDescription>Completed</ItemDescription>
</ItemContent>
</Item>
</div>
{/* Search + Order By */}
<div className="flex items-center gap-3">
<InputGroup className="flex-1">
<InputGroupAddon>
<Search />
</InputGroupAddon>
<InputGroupInput
placeholder="Search tasks or projects..."
value={search}
onChange={(e) => handleSearchChange(e.target.value)}
/>
</InputGroup>
<Select value={orderBy} onValueChange={(v) => setOrderBy(v as OrderBy)}>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="Order by" />
</SelectTrigger>
<SelectContent>
{(Object.entries(ORDER_LABELS) as [OrderBy, string][]).map(([key, label]) => (
<SelectItem key={key} value={key}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Status Filter Tabs + New Task Button */}
<div className="flex items-center justify-between">
<Tabs value={statusFilter} onValueChange={(v) => setStatusFilter(v as StatusFilter)}>
<TabsList>
<TabsTrigger value="all">All</TabsTrigger>
<TabsTrigger value="todo">To Do</TabsTrigger>
<TabsTrigger value="in_progress">In Progress</TabsTrigger>
<TabsTrigger value="done">Completed</TabsTrigger>
</TabsList>
</Tabs>
<Button size="sm" onClick={() => setDialogOpen(true)}>
<Plus className="h-4 w-4 mr-1" />
New Task
</Button>
</div>
{/* Task List */}
<div className="flex flex-col gap-1">
{tasksList.length === 0 ? (
<Empty>
<EmptyHeader>
<EmptyMedia variant="icon">
<ClipboardCheck />
</EmptyMedia>
<EmptyTitle>No tasks found</EmptyTitle>
<EmptyDescription>
Create a new task to get started or adjust your filters.
</EmptyDescription>
</EmptyHeader>
</Empty>
) : (
tasksList.map((task) => (
<TaskRow
key={task.id}
task={task}
onToggle={handleCheckboxToggle}
onEdit={setEditTask}
onDelete={(id) => deleteTask.mutate({ id })}
/>
))
)}
</div>
<NewTaskDialog open={dialogOpen} onOpenChange={setDialogOpen} />
<EditTaskDialog
task={editTask}
open={!!editTask}
onOpenChange={(open: boolean) => { if (!open) setEditTask(null); }}
/>
</div>
);
}

View File

@@ -1,13 +1,110 @@
import { createFileRoute } from '@tanstack/react-router';
import { useState, useMemo } from 'react';
import { Plus } from 'lucide-react';
import { trpc } from '@/lib/trpc';
import { Button } from '@/components/ui/button';
import { GanttChart, type GanttCheckpoint } from '@/components/timeline/GanttChart';
import { AddCheckpointDialog } from '@/components/timeline/AddCheckpointDialog';
export const Route = createFileRoute('/timeline')({
component: TimelinePage,
});
function TimelinePage() {
const [dialogOpen, setDialogOpen] = useState(false);
const { data: checkpoints } = trpc.checkpoints.list.useQuery({});
const { data: projectsList } = trpc.projects.listAll.useQuery();
const utils = trpc.useUtils();
const deleteCheckpoint = trpc.checkpoints.delete.useMutation({
onSuccess: () => {
void utils.checkpoints.list.invalidate();
},
});
// Build project name lookup
const projectMap = useMemo(() => {
const map = new Map<string, string>();
for (const p of projectsList ?? []) {
map.set(p.id, p.name);
}
return map;
}, [projectsList]);
// Map checkpoints to GanttChart format with project names
const ganttCheckpoints: GanttCheckpoint[] = useMemo(() => {
return (checkpoints ?? []).map((cp) => ({
id: cp.id,
title: cp.title,
date: cp.date,
projectId: cp.projectId,
projectName: projectMap.get(cp.projectId),
isAiSuggested: cp.isAiSuggested,
isApproved: cp.isApproved,
}));
}, [checkpoints, projectMap]);
// Compute date range: 1 month before earliest checkpoint or today, 3 months after latest or today
const { startDate, endDate } = useMemo(() => {
const now = new Date();
if (ganttCheckpoints.length === 0) {
const start = new Date(now.getFullYear(), now.getMonth() - 1, 1);
const end = new Date(now.getFullYear(), now.getMonth() + 4, 0);
return { startDate: start, endDate: end };
}
const dates = ganttCheckpoints.map((cp) => cp.date);
const min = Math.min(...dates, now.getTime());
const max = Math.max(...dates, now.getTime());
const start = new Date(new Date(min).getFullYear(), new Date(min).getMonth() - 1, 1);
const end = new Date(new Date(max).getFullYear(), new Date(max).getMonth() + 2, 0);
return { startDate: start, endDate: end };
}, [ganttCheckpoints]);
return (
<div className="flex items-center justify-center h-full text-muted-foreground text-sm">
Timeline coming in US-008
<div className="flex flex-col gap-6 p-6 max-w-[1200px] mx-auto w-full">
{/* Header */}
<div className="flex items-center justify-between">
<h1 className="text-xl font-semibold">Timeline</h1>
<Button size="sm" onClick={() => setDialogOpen(true)}>
<Plus className="h-4 w-4 mr-1" />
Add
</Button>
</div>
{/* Legend */}
<div className="flex items-center gap-4 text-xs text-muted-foreground">
<div className="flex items-center gap-1.5">
<svg width={14} height={14}><circle cx={7} cy={7} r={5} fill="#171717" /></svg>
To Do
</div>
<div className="flex items-center gap-1.5">
<svg width={14} height={14}><circle cx={7} cy={7} r={5} fill="#16a34a" /></svg>
Completed
</div>
<div className="flex items-center gap-1.5">
<svg width={14} height={14}><circle cx={7} cy={7} r={5} fill="none" stroke="#737373" strokeWidth={1.5} strokeDasharray="3 2" /></svg>
AI Suggestion (Pending)
</div>
</div>
{/* Gantt Chart */}
{ganttCheckpoints.length === 0 ? (
<div className="text-center text-muted-foreground text-sm py-12 border rounded-md bg-muted/30">
No checkpoints yet. Click "+ Add" to create your first milestone.
</div>
) : (
<div className="border rounded-md p-4 bg-white">
<GanttChart
checkpoints={ganttCheckpoints}
startDate={startDate}
endDate={endDate}
onDelete={(id) => deleteCheckpoint.mutate({ id })}
/>
</div>
)}
<AddCheckpointDialog open={dialogOpen} onOpenChange={setDialogOpen} />
</div>
);
}