feat: add inline project timeline and notes section to Project Detail

- Implemented a Gantt timeline using GanttChart component scoped to project checkpoints.
- Added functionality to create and manage checkpoints with AddCheckpointDialog.
- Introduced EditCheckpointDialog for editing existing checkpoints.
- Created a notes section displaying a list of notes with the ability to add new notes.
- Updated routing to include notes detail page.
- Enhanced GanttChart with context menu for editing and deleting checkpoints.
- Improved UI components for better user experience.
This commit is contained in:
Roberto Musso
2026-02-22 15:15:30 +01:00
parent 40ac075633
commit 7860ca6ad1
11 changed files with 595 additions and 129 deletions

View File

@@ -23,20 +23,20 @@ APPEND to progress.txt (never replace, always append):
## USER REQUEST
{
"id": "US-014",
"title": "Kanban board in Project Detail",
"description": "As a user, I want a Kanban board inside the project detail view with drag-and-drop task management between status columns.",
"id": "US-015",
"title": "Inline project timeline and notes list in Project Detail",
"description": "As a user, I want to see the project's Gantt timeline and a list of its notes within the project detail scrollable view.",
"acceptanceCriteria": [
"@hello-pangea/dnd installed; DragDropContext wraps 3 Droppable columns: To Do | In Progress | Completed",
"Each task card is a Draggable wrapped in a shadcn/ui Card rendering: title, description (truncated), priority as shadcn/ui Badge, due date chip, assignee string",
"Dragging a card to another column calls tasks.update({ id, status }) via tRPC and the UI updates immediately (optimistic or on success)",
"'+ Add' shadcn/ui Button (variant=ghost, size=sm) in each column header opens the shadcn/ui Dialog new-task modal with the column's status pre-selected",
"Columns show a task count in their header using shadcn/ui Badge (variant=secondary)",
"All card content uses shadcn/ui primitives: Card, Badge, Button (already installed)",
"Project Detail view includes a 'Project Timeline' section using the GanttChart component (from US-012) scoped to the current project's checkpoints",
"'+ Add' shadcn/ui Button (variant=outline, size=sm) in the timeline section header opens the add-checkpoint shadcn/ui Dialog with the project pre-selected",
"Notes section below Kanban shows a flat list using shadcn/ui Separator between rows: each row has note title + formatted createdAt date",
"'+ Add' shadcn/ui Button in notes header calls notes.create with a default title and navigates to /notes/:noteId",
"Clicking a note title navigates to /notes/:noteId",
"All buttons/dialogs use shadcn/ui components (already installed)",
"Typecheck passes",
"Verify in browser using dev-browser skill"
],
"priority": 14,
"priority": 15,
"passes": false,
"notes": ""
}

View File

@@ -278,8 +278,8 @@
"Verify in browser using dev-browser skill"
],
"priority": 15,
"passes": false,
"notes": ""
"passes": true,
"notes": "Completed: Project Timeline section using GanttChart (scoped to project checkpoints) with AddCheckpointDialog (defaultProjectId hides project selector), checkpoint delete mutation. Notes section using Item cards (variant=muted) in flex-wrap grid matching Figma design, '+ Add' creates note via notes.create and navigates to /notes/$noteId, clicking note card navigates to /notes/$noteId. Route stub at notes.$noteId.tsx with back button + note title."
},
{
"id": "US-016",

View File

@@ -249,3 +249,23 @@
- When grouping tasks by status for Kanban columns, always handle unknown/null status values with a fallback to prevent tasks from disappearing
- `DragDropContext.onDragEnd` provides `draggableId` which maps directly to `task.id` — no need to look up the task object for status updates
---
## 2026-02-22 - US-015
- What was implemented:
- Added Project Timeline section to `ProjectDetail.tsx` between AI Summary and Tasks Kanban
- Reused `GanttChart` component (from US-012) scoped to current project's checkpoints
- "+ Add" Button (variant=outline, size=sm) opens `AddCheckpointDialog` with `defaultProjectId={projectId}` (hides project selector)
- Wired `checkpoints.delete` mutation with `onDelete` prop for checkpoint dot deletion
- Computed `ganttStart`/`ganttEnd` dynamically from checkpoint dates with 1-month padding (fallback ±2 months if empty)
- Added Notes section below Tasks Kanban using `Item` component (variant=muted) in a flex-wrap grid layout matching Figma design
- Each note card shows `SquareDashed` icon + title + formatted createdAt date, clickable to navigate to `/notes/$noteId`
- "+ Add" Button calls `notes.create({ title: 'Untitled Note', content: '', projectId })` then navigates to the new note
- Created route stub at `src/renderer/routes/notes.$noteId.tsx` with back button + note title placeholder (full editor deferred to US-016)
- Files changed: `src/renderer/components/projects/ProjectDetail.tsx`, `src/renderer/routes/notes.$noteId.tsx` (new), `prd.json`, `progress.txt`
- **Learnings for future iterations:**
- GanttChart + AddCheckpointDialog are designed for reuse: `defaultProjectId` prop scopes the dialog to a project and hides the project select dropdown
- Figma notes section uses a card grid layout (flex-wrap with Item cards), not a flat list with Separators — always cross-reference Figma when acceptance criteria text diverges
- `trpc.useUtils()` provides `invalidate()` for cache busting after mutations — use at the component level, not inside mutation callbacks
- `notes.create` returns `{ id }` which can be used directly for navigation in the `onSuccess` callback
- TanStack Router file-based routing: `notes.$noteId.tsx` generates `/notes/:noteId` route automatically — `Route.useParams()` provides typed `{ noteId }`
---

View File

@@ -1,5 +1,7 @@
import { useMemo, useState } from 'react';
import { Sparkles, FileText, CheckCircle2, Milestone, Plus } from 'lucide-react';
import { Fragment, useMemo, useState } from 'react';
import { Sparkles, FileText, CheckCircle2, Milestone, Plus, SquareDashed } from 'lucide-react';
import { format } from 'date-fns';
import { useNavigate } from '@tanstack/react-router';
import { trpc } from '@/lib/trpc';
import { Button } from '@/components/ui/button';
import { Item, ItemMedia, ItemContent, ItemTitle, ItemDescription } from '@/components/ui/item';
@@ -10,6 +12,9 @@ import {
BreadcrumbSeparator,
} from '@/components/ui/breadcrumb';
import { KanbanBoard } from './KanbanBoard';
import { GanttChart, type GanttCheckpoint } from '@/components/timeline/GanttChart';
import { AddCheckpointDialog } from '@/components/timeline/AddCheckpointDialog';
import { EditCheckpointDialog } from '@/components/timeline/EditCheckpointDialog';
type ProjectDetailProps = {
projectId: string;
@@ -17,6 +22,10 @@ type ProjectDetailProps = {
export function ProjectDetail({ projectId }: ProjectDetailProps) {
const [newTaskOpen, setNewTaskOpen] = useState(false);
const [addCheckpointOpen, setAddCheckpointOpen] = useState(false);
const [editingCheckpoint, setEditingCheckpoint] = useState<GanttCheckpoint | null>(null);
const navigate = useNavigate();
const utils = trpc.useUtils();
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 });
@@ -54,6 +63,52 @@ export function ProjectDetail({ projectId }: ProjectDetailProps) {
return { approved, total: all.length };
}, [checkpointsList]);
// Map checkpoints to GanttChart format
const ganttCheckpoints: GanttCheckpoint[] = useMemo(() => {
return (checkpointsList ?? []).map((c) => ({
id: c.id,
title: c.title,
date: c.date,
projectId,
isAiSuggested: c.isAiSuggested,
isApproved: c.isApproved,
}));
}, [checkpointsList, projectId]);
const { ganttStart, ganttEnd } = 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() + 3, 0);
return { ganttStart: start, ganttEnd: end };
}
const dates = ganttCheckpoints.map((c) => c.date);
const minDate = new Date(Math.min(...dates));
const maxDate = new Date(Math.max(...dates));
const start = new Date(minDate.getFullYear(), minDate.getMonth() - 1, 1);
const end = new Date(maxDate.getFullYear(), maxDate.getMonth() + 2, 0);
return { ganttStart: start, ganttEnd: end };
}, [ganttCheckpoints]);
const deleteCheckpoint = trpc.checkpoints.delete.useMutation({
onSuccess: () => {
void utils.checkpoints.list.invalidate({ projectId });
},
});
const updateCheckpoint = trpc.checkpoints.update.useMutation({
onSuccess: () => {
void utils.checkpoints.list.invalidate({ projectId });
},
});
const createNote = trpc.notes.create.useMutation({
onSuccess: (data) => {
void utils.notes.list.invalidate({ projectId });
void navigate({ to: '/notes/$noteId', params: { noteId: data.id } });
},
});
if (isLoading) {
return (
<div className="flex items-center justify-center h-full text-sm text-muted-foreground">
@@ -78,10 +133,12 @@ export function ProjectDetail({ projectId }: ProjectDetailProps) {
<Breadcrumb>
<BreadcrumbList>
{breadcrumbPath.map((segment, i) => (
<BreadcrumbItem key={i}>
<Fragment key={i}>
{i > 0 && <BreadcrumbSeparator />}
<span className="text-muted-foreground">{segment}</span>
</BreadcrumbItem>
<BreadcrumbItem>
<span className="text-muted-foreground">{segment}</span>
</BreadcrumbItem>
</Fragment>
))}
</BreadcrumbList>
</Breadcrumb>
@@ -135,11 +192,41 @@ export function ProjectDetail({ projectId }: ProjectDetailProps) {
</ItemContent>
</Item>
{/* Project Timeline */}
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold">Project Timeline</h2>
<Button variant="secondary" size="sm" onClick={() => setAddCheckpointOpen(true)}>
<Plus className="h-4 w-4 mr-1" />
Add
</Button>
</div>
<GanttChart
checkpoints={ganttCheckpoints}
startDate={ganttStart}
endDate={ganttEnd}
onDelete={(id) => deleteCheckpoint.mutate({ id })}
onEdit={(cp) => setEditingCheckpoint(cp)}
onToggleApproval={(id, current) =>
updateCheckpoint.mutate({ id, isApproved: current === 1 ? 0 : 1 })
}
/>
<AddCheckpointDialog
open={addCheckpointOpen}
onOpenChange={setAddCheckpointOpen}
defaultProjectId={projectId}
/>
<EditCheckpointDialog
checkpoint={editingCheckpoint}
onOpenChange={(open) => { if (!open) setEditingCheckpoint(null); }}
/>
</div>
{/* Tasks Kanban */}
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold">Tasks</h2>
<Button variant="ghost" size="sm" onClick={() => setNewTaskOpen(true)}>
<Button variant="secondary" size="sm" onClick={() => setNewTaskOpen(true)}>
<Plus className="h-4 w-4 mr-1" />
Add
</Button>
@@ -150,6 +237,50 @@ export function ProjectDetail({ projectId }: ProjectDetailProps) {
onNewTaskOpenChange={setNewTaskOpen}
/>
</div>
{/* Notes */}
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold">Notes</h2>
<Button
variant="secondary"
size="sm"
disabled={createNote.isPending}
onClick={() =>
createNote.mutate({ title: 'Untitled Note', content: '', projectId })
}
>
<Plus className="h-4 w-4 mr-1" />
Add
</Button>
</div>
{notesList && notesList.length > 0 ? (
<div className="flex flex-wrap gap-5">
{notesList.map((note) => (
<Item
key={note.id}
variant="muted"
className="min-w-[280px] flex-1 cursor-pointer"
onClick={() =>
void navigate({ to: '/notes/$noteId', params: { noteId: note.id } })
}
>
<ItemMedia variant="icon">
<SquareDashed />
</ItemMedia>
<ItemContent>
<ItemTitle>{note.title}</ItemTitle>
<ItemDescription>
{format(new Date(note.createdAt), 'PPP')}
</ItemDescription>
</ItemContent>
</Item>
))}
</div>
) : (
<p className="text-sm text-muted-foreground">No notes yet.</p>
)}
</div>
</div>
);
}

View File

@@ -1,6 +1,6 @@
import { useState } from 'react';
import { format } from 'date-fns';
import { Calendar as CalendarIcon } from 'lucide-react';
import { Calendar as CalendarIcon, Check } from 'lucide-react';
import { trpc } from '@/lib/trpc';
import { Button } from '@/components/ui/button';
import {
@@ -28,10 +28,16 @@ interface AddCheckpointDialogProps {
defaultProjectId?: string;
}
interface AddedEntry {
title: string;
date: Date;
}
export function AddCheckpointDialog({ open, onOpenChange, defaultProjectId }: AddCheckpointDialogProps) {
const [title, setTitle] = useState('');
const [date, setDate] = useState<Date | undefined>();
const [projectId, setProjectId] = useState(defaultProjectId ?? '');
const [added, setAdded] = useState<AddedEntry[]>([]);
const showProjectSelect = !defaultProjectId;
const { data: projectsList } = trpc.projects.listAll.useQuery(undefined, {
@@ -40,16 +46,19 @@ export function AddCheckpointDialog({ open, onOpenChange, defaultProjectId }: Ad
const utils = trpc.useUtils();
const createCheckpoint = trpc.checkpoints.create.useMutation({
onSuccess: () => {
onSuccess: (_data, variables) => {
void utils.checkpoints.list.invalidate();
resetAndClose();
setAdded((prev) => [...prev, { title: variables.title, date: new Date(variables.date) }]);
setTitle('');
setDate(undefined);
},
});
function resetAndClose() {
function handleClose() {
setTitle('');
setDate(undefined);
setProjectId(defaultProjectId ?? '');
setAdded([]);
onOpenChange(false);
}
@@ -68,11 +77,25 @@ export function AddCheckpointDialog({ open, onOpenChange, defaultProjectId }: Ad
const canSubmit = title.trim() && date && (defaultProjectId || projectId);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<Dialog open={open} onOpenChange={(v) => { if (!v) handleClose(); else onOpenChange(v); }}>
<DialogContent className="sm:max-w-[400px]">
<DialogHeader>
<DialogTitle>Add Checkpoint</DialogTitle>
<DialogTitle>Add Checkpoints</DialogTitle>
</DialogHeader>
{/* Just-added list */}
{added.length > 0 && (
<div className="flex flex-col gap-1.5 max-h-32 overflow-y-auto">
{added.map((entry, i) => (
<div key={i} className="flex items-center gap-2 text-sm text-muted-foreground">
<Check className="h-3.5 w-3.5 text-chart-2 shrink-0" />
<span className="truncate">{entry.title}</span>
<span className="ml-auto text-xs shrink-0">{format(entry.date, 'MMM d')}</span>
</div>
))}
</div>
)}
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
<Input
placeholder="Checkpoint title"
@@ -100,6 +123,7 @@ export function AddCheckpointDialog({ open, onOpenChange, defaultProjectId }: Ad
<Calendar
mode="single"
selected={date}
numberOfMonths={2}
onSelect={setDate}
/>
</PopoverContent>
@@ -121,11 +145,11 @@ export function AddCheckpointDialog({ open, onOpenChange, defaultProjectId }: Ad
)}
<DialogFooter>
<Button type="button" variant="outline" onClick={resetAndClose}>
Cancel
<Button type="button" variant="outline" onClick={handleClose}>
{added.length > 0 ? 'Done' : 'Cancel'}
</Button>
<Button type="submit" disabled={!canSubmit || createCheckpoint.isPending}>
Add Checkpoint
{added.length > 0 ? 'Add Another' : 'Add'}
</Button>
</DialogFooter>
</form>

View File

@@ -0,0 +1,107 @@
import { useState, useEffect } 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 { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Calendar } from '@/components/ui/calendar';
import { cn } from '@/lib/utils';
import type { GanttCheckpoint } from './GanttChart';
interface EditCheckpointDialogProps {
checkpoint: GanttCheckpoint | null;
onOpenChange: (open: boolean) => void;
}
export function EditCheckpointDialog({ checkpoint, onOpenChange }: EditCheckpointDialogProps) {
const [title, setTitle] = useState('');
const [date, setDate] = useState<Date | undefined>();
const utils = trpc.useUtils();
useEffect(() => {
if (checkpoint) {
setTitle(checkpoint.title);
setDate(new Date(checkpoint.date));
}
}, [checkpoint]);
const updateCheckpoint = trpc.checkpoints.update.useMutation({
onSuccess: () => {
void utils.checkpoints.list.invalidate();
onOpenChange(false);
},
});
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!checkpoint || !title.trim() || !date) return;
updateCheckpoint.mutate({
id: checkpoint.id,
title: title.trim(),
date: date.getTime(),
});
}
const canSubmit = title.trim() && date;
return (
<Dialog open={!!checkpoint} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[400px]">
<DialogHeader>
<DialogTitle>Edit 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>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button type="submit" disabled={!canSubmit || updateCheckpoint.isPending}>
Save
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}

View File

@@ -1,7 +1,14 @@
import { useRef, useState, useEffect, useCallback } from 'react';
import { useRef, useEffect, useState, useCallback } from 'react';
import { format } from 'date-fns';
import { Pencil, Trash2 } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Button } from '@/components/ui/button';
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuTrigger,
} from '@/components/ui/context-menu';
export interface GanttCheckpoint {
id: string;
@@ -18,12 +25,15 @@ interface GanttChartProps {
startDate: Date;
endDate: Date;
onDelete?: (id: string) => void;
onEdit?: (checkpoint: GanttCheckpoint) => void;
onToggleApproval?: (id: string, currentApproved: number) => void;
}
const HEADER_HEIGHT = 30;
const BASELINE_Y = 70;
const SVG_HEIGHT = 100;
const DOT_RADIUS = 7;
const BASELINE_HEIGHT = 8;
const SVG_HEIGHT = 110;
const DOT_RADIUS = 10;
const PADDING_X = 40;
function getMonthsBetween(start: Date, end: Date): Date[] {
@@ -43,7 +53,14 @@ function dateToX(date: Date, start: Date, end: Date, width: number): number {
return PADDING_X + ratio * (width - PADDING_X * 2);
}
export function GanttChart({ checkpoints, startDate, endDate, onDelete }: GanttChartProps) {
export function GanttChart({
checkpoints,
startDate,
endDate,
onDelete,
onEdit,
onToggleApproval,
}: GanttChartProps) {
const containerRef = useRef<HTMLDivElement>(null);
const [width, setWidth] = useState(600);
@@ -63,10 +80,11 @@ export function GanttChart({ checkpoints, startDate, endDate, onDelete }: GanttC
const months = getMonthsBetween(startDate, endDate);
const todayX = dateToX(new Date(), startDate, endDate, width);
const todayVisible = todayX >= PADDING_X && todayX <= width - PADDING_X;
return (
<div ref={containerRef} className="w-full overflow-hidden">
<svg width={width} height={SVG_HEIGHT} className="select-none">
<svg width={width} height={SVG_HEIGHT} className="select-none overflow-visible">
{/* Month labels */}
{months.map((month) => {
const x = dateToX(month, startDate, endDate, width);
@@ -86,8 +104,8 @@ export function GanttChart({ checkpoints, startDate, endDate, onDelete }: GanttC
x1={x}
y1={HEADER_HEIGHT}
x2={x}
y2={BASELINE_Y + 10}
stroke="#e5e5e5"
y2={BASELINE_Y + 14}
stroke="var(--border)"
strokeWidth={1}
strokeDasharray="4 4"
/>
@@ -95,41 +113,47 @@ export function GanttChart({ checkpoints, startDate, endDate, onDelete }: GanttC
);
})}
{/* Baseline */}
<line
x1={PADDING_X}
y1={BASELINE_Y}
x2={width - PADDING_X}
y2={BASELINE_Y}
stroke="#d4d4d4"
strokeWidth={2}
{/* Baseline — thick rounded bar */}
<rect
x={PADDING_X}
y={BASELINE_Y - BASELINE_HEIGHT / 2}
width={Math.max(0, width - PADDING_X * 2)}
height={BASELINE_HEIGHT}
rx={BASELINE_HEIGHT / 2}
fill="var(--border)"
/>
{/* 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>
{/* Today marker — dashed line */}
{todayVisible && (
<line
x1={todayX}
y1={HEADER_HEIGHT}
x2={todayX}
y2={BASELINE_Y + 14}
stroke="var(--destructive)"
strokeWidth={1.5}
strokeDasharray="4 2"
/>
)}
{/* Checkpoint dots rendered as foreignObject for Popover */}
{/* Today marker — Badge label via foreignObject */}
{todayVisible && (
<foreignObject
x={todayX - 30}
y={BASELINE_Y + 12}
width={60}
height={28}
className="overflow-visible"
>
<div className="flex justify-center">
<Badge variant="outline" className="text-[10px] px-1.5 py-0 border-destructive text-destructive">
Today
</Badge>
</div>
</foreignObject>
)}
{/* Checkpoint dots */}
{checkpoints.map((cp) => {
const cx = dateToX(new Date(cp.date), startDate, endDate, width);
return (
@@ -138,6 +162,8 @@ export function GanttChart({ checkpoints, startDate, endDate, onDelete }: GanttC
checkpoint={cp}
cx={cx}
onDelete={onDelete}
onEdit={onEdit}
onToggleApproval={onToggleApproval}
/>
);
})}
@@ -146,83 +172,131 @@ export function GanttChart({ checkpoints, startDate, endDate, onDelete }: GanttC
);
}
function getStatusLabel(checkpoint: GanttCheckpoint): { text: string; className: string } {
if (checkpoint.isApproved === 0) {
return { text: 'Pending', className: 'bg-muted text-muted-foreground' };
}
if (checkpoint.date < Date.now()) {
return { text: 'Completed', className: 'bg-chart-2/15 text-chart-2' };
}
return { text: 'To Do', className: 'bg-primary/10 text-primary' };
}
function CheckpointDot({
checkpoint,
cx,
onDelete,
onEdit,
onToggleApproval,
}: {
checkpoint: GanttCheckpoint;
cx: number;
onDelete?: (id: string) => void;
onEdit?: (checkpoint: GanttCheckpoint) => void;
onToggleApproval?: (id: string, currentApproved: number) => void;
}) {
const [open, setOpen] = useState(false);
const [hovered, setHovered] = useState(false);
const hoverTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
const handleDelete = useCallback(() => {
onDelete?.(checkpoint.id);
setOpen(false);
}, [onDelete, checkpoint.id]);
const handleMouseEnter = useCallback(() => {
if (hoverTimeout.current) clearTimeout(hoverTimeout.current);
hoverTimeout.current = setTimeout(() => setHovered(true), 200);
}, []);
const handleMouseLeave = useCallback(() => {
if (hoverTimeout.current) clearTimeout(hoverTimeout.current);
hoverTimeout.current = setTimeout(() => setHovered(false), 150);
}, []);
// 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 fill = isPending ? 'none' : (isPast ? 'var(--chart-2)' : 'var(--primary)');
const stroke = isPending ? 'var(--muted-foreground)' : 'none';
const strokeDasharray = isPending ? '3 2' : undefined;
const status = getStatusLabel(checkpoint);
const dotSize = DOT_RADIUS * 2 + 2;
const hitArea = dotSize + 8;
const dotSvg = (
<svg width={dotSize} height={dotSize} className="shrink-0">
<circle
cx={DOT_RADIUS + 1}
cy={DOT_RADIUS + 1}
r={DOT_RADIUS}
fill={fill}
stroke={stroke || 'var(--primary)'}
strokeWidth={isPending ? 1.5 : 0}
strokeDasharray={strokeDasharray}
/>
</svg>
);
return (
<foreignObject
x={cx - 16}
y={BASELINE_Y - 16}
width={32}
height={32}
x={cx - hitArea / 2}
y={BASELINE_Y - hitArea / 2}
width={hitArea}
height={hitArea}
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"
<ContextMenu>
<Popover open={hovered}>
<ContextMenuTrigger asChild>
<PopoverTrigger asChild>
<button
className="flex items-center justify-center w-full h-full focus:outline-none cursor-pointer"
type="button"
onClick={() => onToggleApproval?.(checkpoint.id, checkpoint.isApproved)}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
Delete
</Button>
)}
</div>
</PopoverContent>
</Popover>
{dotSvg}
</button>
</PopoverTrigger>
</ContextMenuTrigger>
<PopoverContent
className="w-52 p-3 pointer-events-none"
side="top"
sideOffset={8}
onOpenAutoFocus={(e) => e.preventDefault()}
>
<div className="flex flex-col gap-1.5">
<div className="flex items-center justify-between gap-2">
<span className="font-semibold text-sm leading-snug truncate">
{checkpoint.title}
</span>
<Badge variant="secondary" className={`text-[10px] px-1.5 py-0 shrink-0 ${status.className}`}>
{status.text}
</Badge>
</div>
<span className="text-xs text-muted-foreground">
{format(new Date(checkpoint.date), 'PPP')}
</span>
{checkpoint.projectName && (
<span className="text-xs text-muted-foreground">
{checkpoint.projectName}
</span>
)}
</div>
</PopoverContent>
</Popover>
<ContextMenuContent>
<ContextMenuItem onSelect={() => onEdit?.(checkpoint)}>
<Pencil className="h-4 w-4 mr-2" />
Edit Checkpoint
</ContextMenuItem>
<ContextMenuItem
onSelect={() => onDelete?.(checkpoint.id)}
className="text-destructive focus:text-destructive"
>
<Trash2 className="h-4 w-4 mr-2" />
Delete Checkpoint
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
</foreignObject>
);
}

View File

@@ -0,0 +1,42 @@
import * as React from "react"
import { HoverCard as HoverCardPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function HoverCard({
...props
}: React.ComponentProps<typeof HoverCardPrimitive.Root>) {
return <HoverCardPrimitive.Root data-slot="hover-card" {...props} />
}
function HoverCardTrigger({
...props
}: React.ComponentProps<typeof HoverCardPrimitive.Trigger>) {
return (
<HoverCardPrimitive.Trigger data-slot="hover-card-trigger" {...props} />
)
}
function HoverCardContent({
className,
align = "center",
sideOffset = 4,
...props
}: React.ComponentProps<typeof HoverCardPrimitive.Content>) {
return (
<HoverCardPrimitive.Portal data-slot="hover-card-portal">
<HoverCardPrimitive.Content
data-slot="hover-card-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-64 origin-(--radix-hover-card-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
className
)}
{...props}
/>
</HoverCardPrimitive.Portal>
)
}
export { HoverCard, HoverCardTrigger, HoverCardContent }

View File

@@ -13,6 +13,7 @@ import { Route as TimelineRouteImport } from './routes/timeline'
import { Route as TasksRouteImport } from './routes/tasks'
import { Route as ProjectsRouteImport } from './routes/projects'
import { Route as IndexRouteImport } from './routes/index'
import { Route as NotesNoteIdRouteImport } from './routes/notes.$noteId'
const TimelineRoute = TimelineRouteImport.update({
id: '/timeline',
@@ -34,18 +35,25 @@ const IndexRoute = IndexRouteImport.update({
path: '/',
getParentRoute: () => rootRouteImport,
} as any)
const NotesNoteIdRoute = NotesNoteIdRouteImport.update({
id: '/notes/$noteId',
path: '/notes/$noteId',
getParentRoute: () => rootRouteImport,
} as any)
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/projects': typeof ProjectsRoute
'/tasks': typeof TasksRoute
'/timeline': typeof TimelineRoute
'/notes/$noteId': typeof NotesNoteIdRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
'/projects': typeof ProjectsRoute
'/tasks': typeof TasksRoute
'/timeline': typeof TimelineRoute
'/notes/$noteId': typeof NotesNoteIdRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
@@ -53,13 +61,14 @@ export interface FileRoutesById {
'/projects': typeof ProjectsRoute
'/tasks': typeof TasksRoute
'/timeline': typeof TimelineRoute
'/notes/$noteId': typeof NotesNoteIdRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths: '/' | '/projects' | '/tasks' | '/timeline'
fullPaths: '/' | '/projects' | '/tasks' | '/timeline' | '/notes/$noteId'
fileRoutesByTo: FileRoutesByTo
to: '/' | '/projects' | '/tasks' | '/timeline'
id: '__root__' | '/' | '/projects' | '/tasks' | '/timeline'
to: '/' | '/projects' | '/tasks' | '/timeline' | '/notes/$noteId'
id: '__root__' | '/' | '/projects' | '/tasks' | '/timeline' | '/notes/$noteId'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
@@ -67,6 +76,7 @@ export interface RootRouteChildren {
ProjectsRoute: typeof ProjectsRoute
TasksRoute: typeof TasksRoute
TimelineRoute: typeof TimelineRoute
NotesNoteIdRoute: typeof NotesNoteIdRoute
}
declare module '@tanstack/react-router' {
@@ -99,6 +109,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof IndexRouteImport
parentRoute: typeof rootRouteImport
}
'/notes/$noteId': {
id: '/notes/$noteId'
path: '/notes/$noteId'
fullPath: '/notes/$noteId'
preLoaderRoute: typeof NotesNoteIdRouteImport
parentRoute: typeof rootRouteImport
}
}
}
@@ -107,6 +124,7 @@ const rootRouteChildren: RootRouteChildren = {
ProjectsRoute: ProjectsRoute,
TasksRoute: TasksRoute,
TimelineRoute: TimelineRoute,
NotesNoteIdRoute: NotesNoteIdRoute,
}
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)

View File

@@ -0,0 +1,34 @@
import { createFileRoute, useNavigate } from '@tanstack/react-router';
import { ArrowLeft } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { trpc } from '@/lib/trpc';
export const Route = createFileRoute('/notes/$noteId')({
component: NoteDetailPage,
});
function NoteDetailPage() {
const { noteId } = Route.useParams();
const navigate = useNavigate();
const { data: note } = trpc.notes.get.useQuery({ id: noteId });
return (
<div className="flex flex-col gap-4 p-6">
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="icon"
onClick={() => void navigate({ to: '/projects' })}
>
<ArrowLeft className="h-4 w-4" />
</Button>
<h1 className="text-2xl font-semibold">
{note?.title ?? 'Loading...'}
</h1>
</div>
<p className="text-muted-foreground text-sm">
Note editor will be implemented in US-016 (Milkdown).
</p>
</div>
);
}

View File

@@ -5,6 +5,7 @@ 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';
import { EditCheckpointDialog } from '@/components/timeline/EditCheckpointDialog';
export const Route = createFileRoute('/timeline')({
component: TimelinePage,
@@ -12,6 +13,7 @@ export const Route = createFileRoute('/timeline')({
function TimelinePage() {
const [dialogOpen, setDialogOpen] = useState(false);
const [editingCheckpoint, setEditingCheckpoint] = useState<GanttCheckpoint | null>(null);
const { data: checkpoints } = trpc.checkpoints.list.useQuery({});
const { data: projectsList } = trpc.projects.listAll.useQuery();
@@ -23,6 +25,12 @@ function TimelinePage() {
},
});
const updateCheckpoint = trpc.checkpoints.update.useMutation({
onSuccess: () => {
void utils.checkpoints.list.invalidate();
},
});
// Build project name lookup
const projectMap = useMemo(() => {
const map = new Map<string, string>();
@@ -75,15 +83,15 @@ function TimelinePage() {
{/* 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>
<svg width={14} height={14}><circle cx={7} cy={7} r={5} fill="var(--primary)" /></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>
<svg width={14} height={14}><circle cx={7} cy={7} r={5} fill="var(--chart-2)" /></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>
<svg width={14} height={14}><circle cx={7} cy={7} r={5} fill="none" stroke="var(--muted-foreground)" strokeWidth={1.5} strokeDasharray="3 2" /></svg>
AI Suggestion (Pending)
</div>
</div>
@@ -100,11 +108,19 @@ function TimelinePage() {
startDate={startDate}
endDate={endDate}
onDelete={(id) => deleteCheckpoint.mutate({ id })}
onEdit={(cp) => setEditingCheckpoint(cp)}
onToggleApproval={(id, current) =>
updateCheckpoint.mutate({ id, isApproved: current === 1 ? 0 : 1 })
}
/>
</div>
)}
<AddCheckpointDialog open={dialogOpen} onOpenChange={setDialogOpen} />
<EditCheckpointDialog
checkpoint={editingCheckpoint}
onOpenChange={(open) => { if (!open) setEditingCheckpoint(null); }}
/>
</div>
);
}