Add biweekly view mode to Gantt chart roadmap

- Add view mode dropdown with "Semaine" (7 days) and "2 semaines" (14 days)
- Biweekly mode shows compact cards (smaller padding, no tablo badge, shorter labels)
- Navigation steps by 1 or 2 weeks based on current view mode
- Dynamic column count and card sizing based on view config

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Arthur Belleville 2026-02-24 10:44:46 +01:00
parent c5ed1f0bf0
commit 349ba4ae7c
No known key found for this signature in database

View file

@ -135,7 +135,13 @@ function formatDateRange(start: Date, end: Date): string {
return `${startStr} - ${endStr}`;
}
const CARD_HEIGHT = 130;
type ViewMode = "weekly" | "biweekly";
const VIEW_CONFIG = {
weekly: { days: 7, label: "Semaine", cardHeight: 130, stepWeeks: 1 },
biweekly: { days: 14, label: "2 semaines", cardHeight: 100, stepWeeks: 2 },
} as const;
const CARD_GAP = 10;
const CARD_TOP_OFFSET = 20;
@ -143,9 +149,14 @@ const CARD_TOP_OFFSET = 20;
export function GanttChart({ tasks, isLoading }: GanttChartProps) {
const [weekOffset, setWeekOffset] = useState(0);
const [viewMode, setViewMode] = useState<ViewMode>("weekly");
const containerRef = useRef<HTMLDivElement>(null);
const [containerWidth, setContainerWidth] = useState(992);
const config = VIEW_CONFIG[viewMode];
const numDays = config.days;
const cardHeight = config.cardHeight;
// Measure container width
useEffect(() => {
const el = containerRef.current;
@ -159,37 +170,37 @@ export function GanttChart({ tasks, isLoading }: GanttChartProps) {
return () => observer.disconnect();
}, []);
const colWidth = containerWidth / 7;
const colWidth = containerWidth / numDays;
// Compute week days
// Compute days
const today = useMemo(() => {
const d = new Date();
d.setHours(0, 0, 0, 0);
return d;
}, []);
const weekStart = useMemo(() => addDays(getMonday(today), weekOffset * 7), [today, weekOffset]);
const weekEnd = useMemo(() => addDays(weekStart, 6), [weekStart]);
const periodStart = useMemo(() => addDays(getMonday(today), weekOffset * 7), [today, weekOffset]);
const periodEnd = useMemo(() => addDays(periodStart, numDays - 1), [periodStart, numDays]);
const days = useMemo(
() => Array.from({ length: 7 }, (_, i) => addDays(weekStart, i)),
[weekStart]
() => Array.from({ length: numDays }, (_, i) => addDays(periodStart, i)),
[periodStart, numDays]
);
// Filter tasks with due_date in this week
// Filter tasks with due_date in this period
const visibleTasks = useMemo(() => {
const start = weekStart.getTime();
const end = addDays(weekStart, 7).getTime();
const start = periodStart.getTime();
const end = addDays(periodStart, numDays).getTime();
return tasks.filter((t) => {
if (!t.due_date) return false;
const d = new Date(t.due_date).getTime();
return d >= start && d < end;
});
}, [tasks, weekStart]);
}, [tasks, periodStart, numDays]);
// Position tasks: group by day column, stack vertically
const positionedTasks = useMemo(() => {
const columnSlots: number[][] = Array.from({ length: 7 }, () => []);
const columnSlots: number[][] = Array.from({ length: numDays }, () => []);
return visibleTasks
.map((task) => {
@ -207,9 +218,9 @@ export function GanttChart({ tasks, isLoading }: GanttChartProps) {
task,
dayIndex,
row,
left: dayIndex * colWidth + 8,
top: CARD_TOP_OFFSET + row * (CARD_HEIGHT + CARD_GAP),
width: colWidth - 16,
left: dayIndex * colWidth + 4,
top: CARD_TOP_OFFSET + row * (cardHeight + CARD_GAP),
width: colWidth - 8,
};
})
.filter(Boolean) as Array<{
@ -220,16 +231,21 @@ export function GanttChart({ tasks, isLoading }: GanttChartProps) {
top: number;
width: number;
}>;
}, [visibleTasks, days, colWidth]);
}, [visibleTasks, days, colWidth, numDays, cardHeight]);
// Compute chart height
const maxRow = positionedTasks.reduce((max, pt) => Math.max(max, pt.row), 0);
const chartHeight = Math.max(400, (maxRow + 1) * (CARD_HEIGHT + CARD_GAP) + CARD_TOP_OFFSET + 20);
const chartHeight = Math.max(400, (maxRow + 1) * (cardHeight + CARD_GAP) + CARD_TOP_OFFSET + 20);
// Today indicator position
const todayIndex = days.findIndex((d) => isSameDay(d, today));
const todayInRange = todayIndex >= 0;
const handleViewModeChange = (mode: ViewMode) => {
setViewMode(mode);
setWeekOffset(0);
};
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
@ -247,20 +263,20 @@ export function GanttChart({ tasks, isLoading }: GanttChartProps) {
variant="outline"
size="icon"
className="rounded-lg"
onClick={() => setWeekOffset((w) => w - 1)}
onClick={() => setWeekOffset((w) => w - config.stepWeeks)}
>
<ChevronLeftIcon className="h-4 w-4" />
</Button>
<div className="px-4 py-2 bg-card border border-border rounded-lg min-w-[200px] text-center">
<span className="text-sm font-medium text-foreground">
{formatDateRange(weekStart, weekEnd)}
{formatDateRange(periodStart, periodEnd)}
</span>
</div>
<Button
variant="outline"
size="icon"
className="rounded-lg"
onClick={() => setWeekOffset((w) => w + 1)}
onClick={() => setWeekOffset((w) => w + config.stepWeeks)}
>
<ChevronRightIcon className="h-4 w-4" />
</Button>
@ -270,11 +286,17 @@ export function GanttChart({ tasks, isLoading }: GanttChartProps) {
<DropdownMenuTrigger asChild>
<Button variant="outline" className="gap-2 rounded-lg">
<CalendarIcon className="h-4 w-4" />
<span>Semaine</span>
<span>{config.label}</span>
<ChevronRightIcon className="h-4 w-4 rotate-90" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => handleViewModeChange("weekly")}>
Semaine
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleViewModeChange("biweekly")}>
2 semaines
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setWeekOffset(0)}>
Aller à aujourd'hui
</DropdownMenuItem>
@ -319,7 +341,7 @@ export function GanttChart({ tasks, isLoading }: GanttChartProps) {
{days.map((_, i) => (
<div
key={i}
className={twMerge("border-border", i < 6 ? "border-r" : "")}
className={twMerge("border-border", i < numDays - 1 ? "border-r" : "")}
style={{ width: colWidth }}
/>
))}
@ -345,11 +367,14 @@ export function GanttChart({ tasks, isLoading }: GanttChartProps) {
STATUS_TEXT_COLORS[pt.task.status ?? "todo"] ?? STATUS_TEXT_COLORS.todo;
const TabloIcon = pt.task.tablos ? getTabloIcon(pt.task.tablos.color) : null;
const isCompact = viewMode === "biweekly";
return (
<div
key={pt.task.id}
className={twMerge(
"absolute rounded-lg p-3 border-l-4 shadow-sm transition-all hover:shadow-md cursor-pointer",
"absolute rounded-lg border-l-4 shadow-sm transition-all hover:shadow-md cursor-pointer overflow-hidden",
isCompact ? "p-2" : "p-3",
status.bg,
status.border
)}
@ -357,19 +382,26 @@ export function GanttChart({ tasks, isLoading }: GanttChartProps) {
left: pt.left,
width: pt.width,
top: pt.top,
minWidth: 160,
minWidth: isCompact ? 80 : 160,
}}
>
{/* Status badge */}
<div className="flex items-center gap-1.5 bg-white w-fit px-2.5 py-1 rounded-full shadow-sm">
<div className="flex items-center gap-1.5 bg-white w-fit px-2 py-0.5 rounded-full shadow-sm">
<span className={twMerge("w-2 h-2 rounded-full", status.dot)} />
<span className={twMerge("text-xs font-medium", textColor)}>
{status.label}
</span>
{!isCompact && (
<span className={twMerge("text-xs font-medium", textColor)}>
{status.label}
</span>
)}
</div>
{/* Title */}
<h3 className="font-semibold text-foreground mt-2 text-sm leading-tight line-clamp-1">
<h3
className={twMerge(
"font-semibold text-foreground leading-tight line-clamp-1",
isCompact ? "mt-1 text-xs" : "mt-2 text-sm"
)}
>
{pt.task.title}
</h3>
@ -378,12 +410,12 @@ export function GanttChart({ tasks, isLoading }: GanttChartProps) {
{new Date(pt.task.due_date!).toLocaleDateString("fr-FR", {
weekday: "short",
day: "numeric",
month: "short",
month: isCompact ? undefined : "short",
})}
</p>
{/* Tablo badge */}
{pt.task.tablos && TabloIcon && (
{/* Tablo badge — hidden in compact mode */}
{!isCompact && pt.task.tablos && TabloIcon && (
<div className="flex items-center gap-2 mt-2">
<div
className={twMerge(
@ -407,7 +439,7 @@ export function GanttChart({ tasks, isLoading }: GanttChartProps) {
<div className="absolute inset-0 flex flex-col items-center justify-center">
<MapIcon className="w-10 h-10 text-muted-foreground/30 mb-2" />
<p className="text-sm text-muted-foreground">
Aucune tâche avec échéance cette semaine
Aucune tâche avec échéance sur cette période
</p>
</div>
)}