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 ## USER REQUEST
{ {
"id": "US-014", "id": "US-015",
"title": "Kanban board in Project Detail", "title": "Inline project timeline and notes list 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.", "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": [ "acceptanceCriteria": [
"@hello-pangea/dnd installed; DragDropContext wraps 3 Droppable columns: To Do | In Progress | Completed", "Project Detail view includes a 'Project Timeline' section using the GanttChart component (from US-012) scoped to the current project's checkpoints",
"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", "'+ 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",
"Dragging a card to another column calls tasks.update({ id, status }) via tRPC and the UI updates immediately (optimistic or on success)", "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 (variant=ghost, size=sm) in each column header opens the shadcn/ui Dialog new-task modal with the column's status pre-selected", "'+ Add' shadcn/ui Button in notes header calls notes.create with a default title and navigates to /notes/:noteId",
"Columns show a task count in their header using shadcn/ui Badge (variant=secondary)", "Clicking a note title navigates to /notes/:noteId",
"All card content uses shadcn/ui primitives: Card, Badge, Button (already installed)", "All buttons/dialogs use shadcn/ui components (already installed)",
"Typecheck passes", "Typecheck passes",
"Verify in browser using dev-browser skill" "Verify in browser using dev-browser skill"
], ],
"priority": 14, "priority": 15,
"passes": false, "passes": false,
"notes": "" "notes": ""
} }

View File

@@ -278,8 +278,8 @@
"Verify in browser using dev-browser skill" "Verify in browser using dev-browser skill"
], ],
"priority": 15, "priority": 15,
"passes": false, "passes": true,
"notes": "" "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", "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 - 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 - `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 { Fragment, useMemo, useState } from 'react';
import { Sparkles, FileText, CheckCircle2, Milestone, Plus } from 'lucide-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 { trpc } from '@/lib/trpc';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Item, ItemMedia, ItemContent, ItemTitle, ItemDescription } from '@/components/ui/item'; import { Item, ItemMedia, ItemContent, ItemTitle, ItemDescription } from '@/components/ui/item';
@@ -10,6 +12,9 @@ import {
BreadcrumbSeparator, BreadcrumbSeparator,
} from '@/components/ui/breadcrumb'; } from '@/components/ui/breadcrumb';
import { KanbanBoard } from './KanbanBoard'; 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 = { type ProjectDetailProps = {
projectId: string; projectId: string;
@@ -17,6 +22,10 @@ type ProjectDetailProps = {
export function ProjectDetail({ projectId }: ProjectDetailProps) { export function ProjectDetail({ projectId }: ProjectDetailProps) {
const [newTaskOpen, setNewTaskOpen] = useState(false); 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: project, isLoading } = trpc.projects.get.useQuery({ id: projectId });
const { data: clientsList } = trpc.clients.list.useQuery(); const { data: clientsList } = trpc.clients.list.useQuery();
const { data: notesList } = trpc.notes.list.useQuery({ projectId }); const { data: notesList } = trpc.notes.list.useQuery({ projectId });
@@ -54,6 +63,52 @@ export function ProjectDetail({ projectId }: ProjectDetailProps) {
return { approved, total: all.length }; return { approved, total: all.length };
}, [checkpointsList]); }, [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) { if (isLoading) {
return ( return (
<div className="flex items-center justify-center h-full text-sm text-muted-foreground"> <div className="flex items-center justify-center h-full text-sm text-muted-foreground">
@@ -78,10 +133,12 @@ export function ProjectDetail({ projectId }: ProjectDetailProps) {
<Breadcrumb> <Breadcrumb>
<BreadcrumbList> <BreadcrumbList>
{breadcrumbPath.map((segment, i) => ( {breadcrumbPath.map((segment, i) => (
<BreadcrumbItem key={i}> <Fragment key={i}>
{i > 0 && <BreadcrumbSeparator />} {i > 0 && <BreadcrumbSeparator />}
<BreadcrumbItem>
<span className="text-muted-foreground">{segment}</span> <span className="text-muted-foreground">{segment}</span>
</BreadcrumbItem> </BreadcrumbItem>
</Fragment>
))} ))}
</BreadcrumbList> </BreadcrumbList>
</Breadcrumb> </Breadcrumb>
@@ -135,11 +192,41 @@ export function ProjectDetail({ projectId }: ProjectDetailProps) {
</ItemContent> </ItemContent>
</Item> </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 */} {/* Tasks Kanban */}
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h2 className="text-lg font-semibold">Tasks</h2> <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" /> <Plus className="h-4 w-4 mr-1" />
Add Add
</Button> </Button>
@@ -150,6 +237,50 @@ export function ProjectDetail({ projectId }: ProjectDetailProps) {
onNewTaskOpenChange={setNewTaskOpen} onNewTaskOpenChange={setNewTaskOpen}
/> />
</div> </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> </div>
); );
} }

View File

@@ -1,6 +1,6 @@
import { useState } from 'react'; import { useState } from 'react';
import { format } from 'date-fns'; 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 { trpc } from '@/lib/trpc';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { import {
@@ -28,10 +28,16 @@ interface AddCheckpointDialogProps {
defaultProjectId?: string; defaultProjectId?: string;
} }
interface AddedEntry {
title: string;
date: Date;
}
export function AddCheckpointDialog({ open, onOpenChange, defaultProjectId }: AddCheckpointDialogProps) { export function AddCheckpointDialog({ open, onOpenChange, defaultProjectId }: AddCheckpointDialogProps) {
const [title, setTitle] = useState(''); const [title, setTitle] = useState('');
const [date, setDate] = useState<Date | undefined>(); const [date, setDate] = useState<Date | undefined>();
const [projectId, setProjectId] = useState(defaultProjectId ?? ''); const [projectId, setProjectId] = useState(defaultProjectId ?? '');
const [added, setAdded] = useState<AddedEntry[]>([]);
const showProjectSelect = !defaultProjectId; const showProjectSelect = !defaultProjectId;
const { data: projectsList } = trpc.projects.listAll.useQuery(undefined, { const { data: projectsList } = trpc.projects.listAll.useQuery(undefined, {
@@ -40,16 +46,19 @@ export function AddCheckpointDialog({ open, onOpenChange, defaultProjectId }: Ad
const utils = trpc.useUtils(); const utils = trpc.useUtils();
const createCheckpoint = trpc.checkpoints.create.useMutation({ const createCheckpoint = trpc.checkpoints.create.useMutation({
onSuccess: () => { onSuccess: (_data, variables) => {
void utils.checkpoints.list.invalidate(); 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(''); setTitle('');
setDate(undefined); setDate(undefined);
setProjectId(defaultProjectId ?? ''); setProjectId(defaultProjectId ?? '');
setAdded([]);
onOpenChange(false); onOpenChange(false);
} }
@@ -68,11 +77,25 @@ export function AddCheckpointDialog({ open, onOpenChange, defaultProjectId }: Ad
const canSubmit = title.trim() && date && (defaultProjectId || projectId); const canSubmit = title.trim() && date && (defaultProjectId || projectId);
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={(v) => { if (!v) handleClose(); else onOpenChange(v); }}>
<DialogContent className="sm:max-w-[400px]"> <DialogContent className="sm:max-w-[400px]">
<DialogHeader> <DialogHeader>
<DialogTitle>Add Checkpoint</DialogTitle> <DialogTitle>Add Checkpoints</DialogTitle>
</DialogHeader> </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"> <form onSubmit={handleSubmit} className="flex flex-col gap-4">
<Input <Input
placeholder="Checkpoint title" placeholder="Checkpoint title"
@@ -100,6 +123,7 @@ export function AddCheckpointDialog({ open, onOpenChange, defaultProjectId }: Ad
<Calendar <Calendar
mode="single" mode="single"
selected={date} selected={date}
numberOfMonths={2}
onSelect={setDate} onSelect={setDate}
/> />
</PopoverContent> </PopoverContent>
@@ -121,11 +145,11 @@ export function AddCheckpointDialog({ open, onOpenChange, defaultProjectId }: Ad
)} )}
<DialogFooter> <DialogFooter>
<Button type="button" variant="outline" onClick={resetAndClose}> <Button type="button" variant="outline" onClick={handleClose}>
Cancel {added.length > 0 ? 'Done' : 'Cancel'}
</Button> </Button>
<Button type="submit" disabled={!canSubmit || createCheckpoint.isPending}> <Button type="submit" disabled={!canSubmit || createCheckpoint.isPending}>
Add Checkpoint {added.length > 0 ? 'Add Another' : 'Add'}
</Button> </Button>
</DialogFooter> </DialogFooter>
</form> </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 { 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 { 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 { export interface GanttCheckpoint {
id: string; id: string;
@@ -18,12 +25,15 @@ interface GanttChartProps {
startDate: Date; startDate: Date;
endDate: Date; endDate: Date;
onDelete?: (id: string) => void; onDelete?: (id: string) => void;
onEdit?: (checkpoint: GanttCheckpoint) => void;
onToggleApproval?: (id: string, currentApproved: number) => void;
} }
const HEADER_HEIGHT = 30; const HEADER_HEIGHT = 30;
const BASELINE_Y = 70; const BASELINE_Y = 70;
const SVG_HEIGHT = 100; const BASELINE_HEIGHT = 8;
const DOT_RADIUS = 7; const SVG_HEIGHT = 110;
const DOT_RADIUS = 10;
const PADDING_X = 40; const PADDING_X = 40;
function getMonthsBetween(start: Date, end: Date): Date[] { 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); 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 containerRef = useRef<HTMLDivElement>(null);
const [width, setWidth] = useState(600); const [width, setWidth] = useState(600);
@@ -63,10 +80,11 @@ export function GanttChart({ checkpoints, startDate, endDate, onDelete }: GanttC
const months = getMonthsBetween(startDate, endDate); const months = getMonthsBetween(startDate, endDate);
const todayX = dateToX(new Date(), startDate, endDate, width); const todayX = dateToX(new Date(), startDate, endDate, width);
const todayVisible = todayX >= PADDING_X && todayX <= width - PADDING_X;
return ( return (
<div ref={containerRef} className="w-full overflow-hidden"> <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 */} {/* Month labels */}
{months.map((month) => { {months.map((month) => {
const x = dateToX(month, startDate, endDate, width); const x = dateToX(month, startDate, endDate, width);
@@ -86,8 +104,8 @@ export function GanttChart({ checkpoints, startDate, endDate, onDelete }: GanttC
x1={x} x1={x}
y1={HEADER_HEIGHT} y1={HEADER_HEIGHT}
x2={x} x2={x}
y2={BASELINE_Y + 10} y2={BASELINE_Y + 14}
stroke="#e5e5e5" stroke="var(--border)"
strokeWidth={1} strokeWidth={1}
strokeDasharray="4 4" strokeDasharray="4 4"
/> />
@@ -95,41 +113,47 @@ export function GanttChart({ checkpoints, startDate, endDate, onDelete }: GanttC
); );
})} })}
{/* Baseline */} {/* Baseline — thick rounded bar */}
<line <rect
x1={PADDING_X} x={PADDING_X}
y1={BASELINE_Y} y={BASELINE_Y - BASELINE_HEIGHT / 2}
x2={width - PADDING_X} width={Math.max(0, width - PADDING_X * 2)}
y2={BASELINE_Y} height={BASELINE_HEIGHT}
stroke="#d4d4d4" rx={BASELINE_HEIGHT / 2}
strokeWidth={2} fill="var(--border)"
/> />
{/* Today marker */} {/* Today marker — dashed line */}
{todayX >= PADDING_X && todayX <= width - PADDING_X && ( {todayVisible && (
<g>
<line <line
x1={todayX} x1={todayX}
y1={HEADER_HEIGHT} y1={HEADER_HEIGHT}
x2={todayX} x2={todayX}
y2={BASELINE_Y + 10} y2={BASELINE_Y + 14}
stroke="#ef4444" stroke="var(--destructive)"
strokeWidth={1.5} strokeWidth={1.5}
strokeDasharray="4 2"
/> />
<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 */} {/* 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) => { {checkpoints.map((cp) => {
const cx = dateToX(new Date(cp.date), startDate, endDate, width); const cx = dateToX(new Date(cp.date), startDate, endDate, width);
return ( return (
@@ -138,6 +162,8 @@ export function GanttChart({ checkpoints, startDate, endDate, onDelete }: GanttC
checkpoint={cp} checkpoint={cp}
cx={cx} cx={cx}
onDelete={onDelete} 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({ function CheckpointDot({
checkpoint, checkpoint,
cx, cx,
onDelete, onDelete,
onEdit,
onToggleApproval,
}: { }: {
checkpoint: GanttCheckpoint; checkpoint: GanttCheckpoint;
cx: number; cx: number;
onDelete?: (id: string) => void; 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(() => { const handleMouseEnter = useCallback(() => {
onDelete?.(checkpoint.id); if (hoverTimeout.current) clearTimeout(hoverTimeout.current);
setOpen(false); hoverTimeout.current = setTimeout(() => setHovered(true), 200);
}, [onDelete, checkpoint.id]); }, []);
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 isPending = checkpoint.isApproved === 0;
const isPast = checkpoint.date < Date.now(); const isPast = checkpoint.date < Date.now();
const fill = isPending ? 'none' : (isPast ? '#16a34a' : '#171717'); const fill = isPending ? 'none' : (isPast ? 'var(--chart-2)' : 'var(--primary)');
const stroke = isPending ? '#737373' : 'none'; const stroke = isPending ? 'var(--muted-foreground)' : 'none';
const strokeDasharray = isPending ? '3 2' : undefined; const strokeDasharray = isPending ? '3 2' : undefined;
const status = getStatusLabel(checkpoint);
return ( const dotSize = DOT_RADIUS * 2 + 2;
<foreignObject const hitArea = dotSize + 8;
x={cx - 16}
y={BASELINE_Y - 16} const dotSvg = (
width={32} <svg width={dotSize} height={dotSize} className="shrink-0">
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 <circle
cx={DOT_RADIUS + 1} cx={DOT_RADIUS + 1}
cy={DOT_RADIUS + 1} cy={DOT_RADIUS + 1}
r={DOT_RADIUS} r={DOT_RADIUS}
fill={fill} fill={fill}
stroke={stroke || '#171717'} stroke={stroke || 'var(--primary)'}
strokeWidth={isPending ? 1.5 : 0} strokeWidth={isPending ? 1.5 : 0}
strokeDasharray={strokeDasharray} strokeDasharray={strokeDasharray}
/> />
</svg> </svg>
);
return (
<foreignObject
x={cx - hitArea / 2}
y={BASELINE_Y - hitArea / 2}
width={hitArea}
height={hitArea}
className="overflow-visible"
>
<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}
>
{dotSvg}
</button> </button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent className="w-60 p-3" side="top"> </ContextMenuTrigger>
<div className="flex flex-col gap-2">
<div className="font-semibold text-sm">{checkpoint.title}</div> <PopoverContent
<div className="text-xs text-muted-foreground"> className="w-52 p-3 pointer-events-none"
{format(new Date(checkpoint.date), 'PPP')} side="top"
</div> sideOffset={8}
{checkpoint.projectName && ( onOpenAutoFocus={(e) => e.preventDefault()}
<div className="text-xs text-muted-foreground">
Project: {checkpoint.projectName}
</div>
)}
{onDelete && (
<Button
variant="destructive"
size="sm"
onClick={handleDelete}
className="mt-1"
> >
Delete <div className="flex flex-col gap-1.5">
</Button> <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> </div>
</PopoverContent> </PopoverContent>
</Popover> </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> </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 TasksRouteImport } from './routes/tasks'
import { Route as ProjectsRouteImport } from './routes/projects' import { Route as ProjectsRouteImport } from './routes/projects'
import { Route as IndexRouteImport } from './routes/index' import { Route as IndexRouteImport } from './routes/index'
import { Route as NotesNoteIdRouteImport } from './routes/notes.$noteId'
const TimelineRoute = TimelineRouteImport.update({ const TimelineRoute = TimelineRouteImport.update({
id: '/timeline', id: '/timeline',
@@ -34,18 +35,25 @@ const IndexRoute = IndexRouteImport.update({
path: '/', path: '/',
getParentRoute: () => rootRouteImport, getParentRoute: () => rootRouteImport,
} as any) } as any)
const NotesNoteIdRoute = NotesNoteIdRouteImport.update({
id: '/notes/$noteId',
path: '/notes/$noteId',
getParentRoute: () => rootRouteImport,
} as any)
export interface FileRoutesByFullPath { export interface FileRoutesByFullPath {
'/': typeof IndexRoute '/': typeof IndexRoute
'/projects': typeof ProjectsRoute '/projects': typeof ProjectsRoute
'/tasks': typeof TasksRoute '/tasks': typeof TasksRoute
'/timeline': typeof TimelineRoute '/timeline': typeof TimelineRoute
'/notes/$noteId': typeof NotesNoteIdRoute
} }
export interface FileRoutesByTo { export interface FileRoutesByTo {
'/': typeof IndexRoute '/': typeof IndexRoute
'/projects': typeof ProjectsRoute '/projects': typeof ProjectsRoute
'/tasks': typeof TasksRoute '/tasks': typeof TasksRoute
'/timeline': typeof TimelineRoute '/timeline': typeof TimelineRoute
'/notes/$noteId': typeof NotesNoteIdRoute
} }
export interface FileRoutesById { export interface FileRoutesById {
__root__: typeof rootRouteImport __root__: typeof rootRouteImport
@@ -53,13 +61,14 @@ export interface FileRoutesById {
'/projects': typeof ProjectsRoute '/projects': typeof ProjectsRoute
'/tasks': typeof TasksRoute '/tasks': typeof TasksRoute
'/timeline': typeof TimelineRoute '/timeline': typeof TimelineRoute
'/notes/$noteId': typeof NotesNoteIdRoute
} }
export interface FileRouteTypes { export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath fileRoutesByFullPath: FileRoutesByFullPath
fullPaths: '/' | '/projects' | '/tasks' | '/timeline' fullPaths: '/' | '/projects' | '/tasks' | '/timeline' | '/notes/$noteId'
fileRoutesByTo: FileRoutesByTo fileRoutesByTo: FileRoutesByTo
to: '/' | '/projects' | '/tasks' | '/timeline' to: '/' | '/projects' | '/tasks' | '/timeline' | '/notes/$noteId'
id: '__root__' | '/' | '/projects' | '/tasks' | '/timeline' id: '__root__' | '/' | '/projects' | '/tasks' | '/timeline' | '/notes/$noteId'
fileRoutesById: FileRoutesById fileRoutesById: FileRoutesById
} }
export interface RootRouteChildren { export interface RootRouteChildren {
@@ -67,6 +76,7 @@ export interface RootRouteChildren {
ProjectsRoute: typeof ProjectsRoute ProjectsRoute: typeof ProjectsRoute
TasksRoute: typeof TasksRoute TasksRoute: typeof TasksRoute
TimelineRoute: typeof TimelineRoute TimelineRoute: typeof TimelineRoute
NotesNoteIdRoute: typeof NotesNoteIdRoute
} }
declare module '@tanstack/react-router' { declare module '@tanstack/react-router' {
@@ -99,6 +109,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof IndexRouteImport preLoaderRoute: typeof IndexRouteImport
parentRoute: typeof rootRouteImport 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, ProjectsRoute: ProjectsRoute,
TasksRoute: TasksRoute, TasksRoute: TasksRoute,
TimelineRoute: TimelineRoute, TimelineRoute: TimelineRoute,
NotesNoteIdRoute: NotesNoteIdRoute,
} }
export const routeTree = rootRouteImport export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren) ._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 { Button } from '@/components/ui/button';
import { GanttChart, type GanttCheckpoint } from '@/components/timeline/GanttChart'; import { GanttChart, type GanttCheckpoint } from '@/components/timeline/GanttChart';
import { AddCheckpointDialog } from '@/components/timeline/AddCheckpointDialog'; import { AddCheckpointDialog } from '@/components/timeline/AddCheckpointDialog';
import { EditCheckpointDialog } from '@/components/timeline/EditCheckpointDialog';
export const Route = createFileRoute('/timeline')({ export const Route = createFileRoute('/timeline')({
component: TimelinePage, component: TimelinePage,
@@ -12,6 +13,7 @@ export const Route = createFileRoute('/timeline')({
function TimelinePage() { function TimelinePage() {
const [dialogOpen, setDialogOpen] = useState(false); const [dialogOpen, setDialogOpen] = useState(false);
const [editingCheckpoint, setEditingCheckpoint] = useState<GanttCheckpoint | null>(null);
const { data: checkpoints } = trpc.checkpoints.list.useQuery({}); const { data: checkpoints } = trpc.checkpoints.list.useQuery({});
const { data: projectsList } = trpc.projects.listAll.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 // Build project name lookup
const projectMap = useMemo(() => { const projectMap = useMemo(() => {
const map = new Map<string, string>(); const map = new Map<string, string>();
@@ -75,15 +83,15 @@ function TimelinePage() {
{/* Legend */} {/* Legend */}
<div className="flex items-center gap-4 text-xs text-muted-foreground"> <div className="flex items-center gap-4 text-xs text-muted-foreground">
<div className="flex items-center gap-1.5"> <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 To Do
</div> </div>
<div className="flex items-center gap-1.5"> <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 Completed
</div> </div>
<div className="flex items-center gap-1.5"> <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) AI Suggestion (Pending)
</div> </div>
</div> </div>
@@ -100,11 +108,19 @@ function TimelinePage() {
startDate={startDate} startDate={startDate}
endDate={endDate} endDate={endDate}
onDelete={(id) => deleteCheckpoint.mutate({ id })} onDelete={(id) => deleteCheckpoint.mutate({ id })}
onEdit={(cp) => setEditingCheckpoint(cp)}
onToggleApproval={(id, current) =>
updateCheckpoint.mutate({ id, isApproved: current === 1 ? 0 : 1 })
}
/> />
</div> </div>
)} )}
<AddCheckpointDialog open={dialogOpen} onOpenChange={setDialogOpen} /> <AddCheckpointDialog open={dialogOpen} onOpenChange={setDialogOpen} />
<EditCheckpointDialog
checkpoint={editingCheckpoint}
onOpenChange={(open) => { if (!open) setEditingCheckpoint(null); }}
/>
</div> </div>
); );
} }