Private
Public Access
1
0

Add Gantt chart view with card dependencies and start dates

Adds a new Gantt chart board view with custom SVG timeline rendering,
card-to-card dependency tracking with cycle detection, and a startDate
field on cards. Includes zoom controls (day/week/month), dependency
arrows, column-grouped task list, today marker, and real-time updates.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Catherine Renelle
2026-02-28 10:03:11 -05:00
parent 85bda718cc
commit 2fbd3bc93d
13 changed files with 1239 additions and 0 deletions
+23
View File
@@ -2,6 +2,7 @@
import { onMount } from 'svelte';
import { trapFocus } from '$lib/utils/focus-trap.js';
import MarkdownEditor from './MarkdownEditor.svelte';
import StartDatePicker from './StartDatePicker.svelte';
import DueDatePicker from './DueDatePicker.svelte';
import TagPicker from './TagPicker.svelte';
import AssigneePicker from './AssigneePicker.svelte';
@@ -10,6 +11,7 @@
import AttachmentList from './AttachmentList.svelte';
import ActivityFeed from './ActivityFeed.svelte';
import CustomFieldsSection from './CustomFieldsSection.svelte';
import DependencySection from './DependencySection.svelte';
import { api } from '$lib/utils/api.js';
type Props = {
@@ -91,6 +93,12 @@
onupdate(card);
}
function updateStartDate(startDate: string | null) {
card.startDate = startDate;
if (fullCard) fullCard.startDate = startDate;
onupdate(card);
}
function updateDueDate(dueDate: string | null) {
card.dueDate = dueDate;
if (fullCard) fullCard.dueDate = dueDate;
@@ -275,6 +283,15 @@
<!-- Sidebar -->
<div class="w-full md:w-48 space-y-4 flex-shrink-0">
<StartDatePicker
value={card.startDate || null}
{canEdit}
{boardId}
{columnId}
cardId={card.id}
onupdate={updateStartDate}
/>
<DueDatePicker
value={card.dueDate || null}
{canEdit}
@@ -294,6 +311,12 @@
onupdate={updateAssignees}
/>
<DependencySection
cardId={card.id}
{boardId}
{canEdit}
/>
<!-- Actions -->
{#if canEdit}
<div>
+201
View File
@@ -0,0 +1,201 @@
<script lang="ts">
import { onMount } from 'svelte';
import { api } from '$lib/utils/api.js';
type Props = {
cardId: string;
boardId: string;
canEdit: boolean;
};
let { cardId, boardId, canEdit }: Props = $props();
type Dep = { id: string; blockingCardId: string; blockedCardId: string; blockingCard: { id: string; title: string }; blockedCard: { id: string; title: string } };
let blocks: Dep[] = $state([]);
let blockedBy: Dep[] = $state([]);
let loading = $state(true);
let adding: 'blocks' | 'blockedBy' | null = $state(null);
let searchQuery = $state('');
let searchResults: any[] = $state([]);
let searching = $state(false);
onMount(async () => {
await loadDeps();
});
async function loadDeps() {
loading = true;
const { ok, data } = await api(`/api/boards/${boardId}/dependencies`);
if (ok) {
const deps: Dep[] = data.dependencies;
blocks = deps.filter((d) => d.blockingCardId === cardId);
blockedBy = deps.filter((d) => d.blockedCardId === cardId);
}
loading = false;
}
async function search(query: string) {
if (!query.trim()) {
searchResults = [];
return;
}
searching = true;
const { ok, data } = await api(`/api/search?q=${encodeURIComponent(query)}&boardId=${boardId}`);
if (ok) {
// Filter out current card and already-linked cards
const existingIds = new Set([
cardId,
...blocks.map((d) => d.blockedCardId),
...blockedBy.map((d) => d.blockingCardId)
]);
searchResults = (data.results || []).filter((r: any) => !existingIds.has(r.id) && r.boardId === boardId);
}
searching = false;
}
let searchTimeout: ReturnType<typeof setTimeout>;
function onSearchInput(e: Event) {
const val = (e.target as HTMLInputElement).value;
searchQuery = val;
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => search(val), 300);
}
async function addDependency(targetCardId: string) {
const body = adding === 'blocks'
? { blockingCardId: cardId, blockedCardId: targetCardId }
: { blockingCardId: targetCardId, blockedCardId: cardId };
const { ok } = await api(`/api/boards/${boardId}/dependencies`, {
method: 'POST',
body: JSON.stringify(body)
});
if (ok) {
adding = null;
searchQuery = '';
searchResults = [];
await loadDeps();
}
}
async function removeDependency(depId: string) {
const { ok } = await api(`/api/boards/${boardId}/dependencies`, {
method: 'DELETE',
body: JSON.stringify({ id: depId })
});
if (ok) {
await loadDeps();
}
}
</script>
<div>
<h3 class="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase mb-2">Dependencies</h3>
{#if loading}
<div class="text-xs text-gray-400 dark:text-gray-500">Loading...</div>
{:else}
<!-- Blocks list -->
{#if blocks.length > 0}
<div class="mb-2">
<p class="text-[10px] uppercase text-gray-400 dark:text-gray-500 mb-1">Blocks</p>
{#each blocks as dep}
<div class="flex items-center justify-between gap-1 py-0.5">
<span class="text-xs text-gray-600 dark:text-gray-300 truncate">{dep.blockedCard.title}</span>
{#if canEdit}
<button
onclick={() => removeDependency(dep.id)}
class="text-gray-400 hover:text-red-500 flex-shrink-0"
aria-label="Remove dependency"
>
<svg class="w-3 h-3" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd" />
</svg>
</button>
{/if}
</div>
{/each}
</div>
{/if}
<!-- Blocked by list -->
{#if blockedBy.length > 0}
<div class="mb-2">
<p class="text-[10px] uppercase text-gray-400 dark:text-gray-500 mb-1">Blocked by</p>
{#each blockedBy as dep}
<div class="flex items-center justify-between gap-1 py-0.5">
<span class="text-xs text-gray-600 dark:text-gray-300 truncate">{dep.blockingCard.title}</span>
{#if canEdit}
<button
onclick={() => removeDependency(dep.id)}
class="text-gray-400 hover:text-red-500 flex-shrink-0"
aria-label="Remove dependency"
>
<svg class="w-3 h-3" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd" />
</svg>
</button>
{/if}
</div>
{/each}
</div>
{/if}
<!-- Add buttons -->
{#if canEdit}
{#if adding}
<div class="mt-1">
<p class="text-[10px] uppercase text-gray-400 dark:text-gray-500 mb-1">
{adding === 'blocks' ? 'This card blocks...' : 'This card is blocked by...'}
</p>
<input
type="text"
placeholder="Search cards..."
value={searchQuery}
oninput={onSearchInput}
class="w-full rounded border border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 px-2 py-1 text-xs mb-1"
autofocus
/>
{#if searchResults.length > 0}
<div class="max-h-32 overflow-y-auto border border-gray-200 dark:border-gray-700 rounded bg-white dark:bg-gray-800">
{#each searchResults as result}
<button
onclick={() => addDependency(result.id)}
class="w-full text-left px-2 py-1 text-xs text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 truncate"
>
{result.title}
</button>
{/each}
</div>
{:else if searchQuery && !searching}
<p class="text-xs text-gray-400 dark:text-gray-500">No cards found</p>
{/if}
<button
onclick={() => { adding = null; searchQuery = ''; searchResults = []; }}
class="text-xs text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 mt-1"
>
Cancel
</button>
</div>
{:else}
<div class="flex gap-2 mt-1">
<button
onclick={() => adding = 'blocks'}
class="text-xs text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300"
>
+ Blocks
</button>
<button
onclick={() => adding = 'blockedBy'}
class="text-xs text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300"
>
+ Blocked by
</button>
</div>
{/if}
{/if}
{/if}
</div>
+513
View File
@@ -0,0 +1,513 @@
<script lang="ts">
import { goto } from '$app/navigation';
import {
eachDayOfInterval,
differenceInCalendarDays,
format,
startOfDay,
addDays,
subDays,
isWeekend,
startOfWeek,
startOfMonth,
isSameMonth
} from 'date-fns';
type Props = {
cards: any[];
columns: { id: string; title: string }[];
dependencies: { id: string; blockingCardId: string; blockedCardId: string }[];
boardId: string;
cardPrefix?: string | null;
};
let { cards, columns, dependencies, boardId, cardPrefix }: Props = $props();
// Zoom levels: pixels per day
type ZoomLevel = 'day' | 'week' | 'month';
let zoom: ZoomLevel = $state('day');
const pxPerDay = $derived(zoom === 'day' ? 32 : zoom === 'week' ? 12 : 4);
const ROW_HEIGHT = 36;
const HEADER_HEIGHT = 48;
const TASK_LIST_WIDTH = 260;
const BAR_HEIGHT = 22;
const BAR_Y_OFFSET = (ROW_HEIGHT - BAR_HEIGHT) / 2;
const DIAMOND_SIZE = 8;
// Column colors palette
const COLUMN_COLORS = [
'#3b82f6', // blue
'#10b981', // emerald
'#f59e0b', // amber
'#8b5cf6', // violet
'#ef4444', // red
'#06b6d4', // cyan
'#f97316', // orange
'#ec4899', // pink
];
// Group cards by column
const groupedCards = $derived.by(() => {
const groups: { column: { id: string; title: string }; cards: any[] }[] = [];
for (const col of columns) {
const colCards = cards.filter((c: any) => c.columnId === col.id);
if (colCards.length > 0) {
groups.push({ column: col, cards: colCards });
}
}
return groups;
});
// Build flat row list with column headers
type Row = { type: 'column'; column: { id: string; title: string } } | { type: 'card'; card: any; columnIndex: number };
const rows = $derived.by(() => {
const result: Row[] = [];
let colIdx = 0;
for (const group of groupedCards) {
result.push({ type: 'column', column: group.column });
for (const card of group.cards) {
result.push({ type: 'card', card, columnIndex: colIdx });
}
colIdx++;
}
return result;
});
// Timeline range: from earliest date - 7 days to latest date + 14 days
const timelineRange = $derived.by(() => {
const today = startOfDay(new Date());
let earliest = today;
let latest = today;
for (const card of cards) {
if (card.startDate) {
const d = startOfDay(new Date(card.startDate));
if (d < earliest) earliest = d;
if (d > latest) latest = d;
}
if (card.dueDate) {
const d = startOfDay(new Date(card.dueDate));
if (d < earliest) earliest = d;
if (d > latest) latest = d;
}
}
const start = subDays(startOfWeek(earliest), 7);
const end = addDays(latest, 21);
return { start, end };
});
const days = $derived(eachDayOfInterval({ start: timelineRange.start, end: timelineRange.end }));
const totalWidth = $derived(days.length * pxPerDay);
// Month headers
const monthHeaders = $derived.by(() => {
const headers: { label: string; x: number; width: number }[] = [];
let currentMonth = '';
let startX = 0;
for (let i = 0; i < days.length; i++) {
const monthLabel = format(days[i], 'MMM yyyy');
if (monthLabel !== currentMonth) {
if (currentMonth) {
headers[headers.length - 1].width = i * pxPerDay - startX;
}
currentMonth = monthLabel;
startX = i * pxPerDay;
headers.push({ label: monthLabel, x: startX, width: 0 });
}
}
if (headers.length > 0) {
headers[headers.length - 1].width = days.length * pxPerDay - headers[headers.length - 1].x;
}
return headers;
});
// Today marker position
const todayOffset = $derived.by(() => {
const today = startOfDay(new Date());
const diff = differenceInCalendarDays(today, timelineRange.start);
if (diff < 0 || diff >= days.length) return null;
return diff * pxPerDay + pxPerDay / 2;
});
// Get bar position for a card
function getBarInfo(card: any): { x: number; width: number; type: 'bar' | 'diamond' | 'dashed' } | null {
const start = card.startDate ? startOfDay(new Date(card.startDate)) : null;
const due = card.dueDate ? startOfDay(new Date(card.dueDate)) : null;
if (start && due) {
const x = differenceInCalendarDays(start, timelineRange.start) * pxPerDay;
const duration = differenceInCalendarDays(due, start) + 1;
return { x, width: Math.max(duration * pxPerDay, pxPerDay), type: 'bar' };
}
if (due) {
const x = differenceInCalendarDays(due, timelineRange.start) * pxPerDay + pxPerDay / 2;
return { x, width: 0, type: 'diamond' };
}
if (start) {
const today = startOfDay(new Date());
const x = differenceInCalendarDays(start, timelineRange.start) * pxPerDay;
const endX = differenceInCalendarDays(today, timelineRange.start) * pxPerDay + pxPerDay / 2;
return { x, width: Math.max(endX - x, pxPerDay), type: 'dashed' };
}
return null;
}
function getColumnColor(colIdx: number): string {
return COLUMN_COLORS[colIdx % COLUMN_COLORS.length];
}
// Build a map from card id to its row index (card rows only) for dependency arrows
const cardRowMap = $derived.by(() => {
const map = new Map<string, number>();
let rowIdx = 0;
for (const row of rows) {
if (row.type === 'card') {
map.set(row.card.id, rowIdx);
}
rowIdx++;
}
return map;
});
// Dependency arrow paths
const dependencyPaths = $derived.by(() => {
const paths: { d: string; id: string }[] = [];
for (const dep of dependencies) {
const fromRow = cardRowMap.get(dep.blockingCardId);
const toRow = cardRowMap.get(dep.blockedCardId);
if (fromRow === undefined || toRow === undefined) continue;
const fromCard = cards.find((c: any) => c.id === dep.blockingCardId);
const toCard = cards.find((c: any) => c.id === dep.blockedCardId);
if (!fromCard || !toCard) continue;
const fromBar = getBarInfo(fromCard);
const toBar = getBarInfo(toCard);
if (!fromBar || !toBar) continue;
// Start point: right end of blocking card's bar
const fromY = fromRow * ROW_HEIGHT + ROW_HEIGHT / 2;
let fromX: number;
if (fromBar.type === 'diamond') {
fromX = fromBar.x + DIAMOND_SIZE;
} else {
fromX = fromBar.x + fromBar.width;
}
// End point: left end of blocked card's bar
const toY = toRow * ROW_HEIGHT + ROW_HEIGHT / 2;
let toX: number;
if (toBar.type === 'diamond') {
toX = toBar.x - DIAMOND_SIZE;
} else {
toX = toBar.x;
}
// Bezier curve
const midX = (fromX + toX) / 2;
const d = `M ${fromX} ${fromY} C ${midX} ${fromY}, ${midX} ${toY}, ${toX} ${toY}`;
paths.push({ d, id: dep.id });
}
return paths;
});
function handleCardClick(card: any) {
goto(`/boards/${boardId}?card=${card.id}`);
}
// Sync scroll between task list and timeline
let timelineEl: HTMLDivElement | undefined = $state();
let taskListEl: HTMLDivElement | undefined = $state();
function onTimelineScroll() {
if (timelineEl && taskListEl) {
taskListEl.scrollTop = timelineEl.scrollTop;
}
}
function onTaskListScroll() {
if (timelineEl && taskListEl) {
timelineEl.scrollTop = taskListEl.scrollTop;
}
}
// Scroll to today on mount
import { onMount } from 'svelte';
onMount(() => {
if (timelineEl && todayOffset !== null) {
timelineEl.scrollLeft = Math.max(0, todayOffset - timelineEl.clientWidth / 3);
}
});
</script>
<div class="flex flex-col h-full">
<!-- Toolbar -->
<div class="flex items-center gap-2 px-4 py-2 border-b border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900">
<span class="text-xs text-gray-500 dark:text-gray-400">Zoom:</span>
<div class="flex gap-1 bg-gray-100 dark:bg-gray-800 rounded p-0.5">
<button
onclick={() => zoom = 'day'}
class="px-2 py-0.5 text-xs rounded {zoom === 'day' ? 'bg-white dark:bg-gray-700 shadow-sm font-medium text-gray-900 dark:text-gray-100' : 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300'}"
>
Day
</button>
<button
onclick={() => zoom = 'week'}
class="px-2 py-0.5 text-xs rounded {zoom === 'week' ? 'bg-white dark:bg-gray-700 shadow-sm font-medium text-gray-900 dark:text-gray-100' : 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300'}"
>
Week
</button>
<button
onclick={() => zoom = 'month'}
class="px-2 py-0.5 text-xs rounded {zoom === 'month' ? 'bg-white dark:bg-gray-700 shadow-sm font-medium text-gray-900 dark:text-gray-100' : 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300'}"
>
Month
</button>
</div>
<span class="text-xs text-gray-400 dark:text-gray-500 ml-2">{cards.length} card{cards.length !== 1 ? 's' : ''}</span>
</div>
<!-- Main area -->
<div class="flex flex-1 overflow-hidden">
<!-- Task list (left panel) -->
<div class="flex flex-col border-r border-gray-200 dark:border-gray-700" style="width: {TASK_LIST_WIDTH}px; min-width: {TASK_LIST_WIDTH}px;">
<!-- Header -->
<div class="h-12 flex items-end px-3 pb-1 border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/50">
<span class="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase">Tasks</span>
</div>
<!-- Scrollable task list -->
<div
class="flex-1 overflow-y-auto overflow-x-hidden"
bind:this={taskListEl}
onscroll={onTaskListScroll}
style="-ms-overflow-style: none; scrollbar-width: none;"
>
{#each rows as row, i}
{#if row.type === 'column'}
<div
class="flex items-center px-3 bg-gray-50 dark:bg-gray-800/50 border-b border-gray-100 dark:border-gray-700/50"
style="height: {ROW_HEIGHT}px;"
>
<span class="text-xs font-semibold text-gray-600 dark:text-gray-300 uppercase tracking-wide truncate">
{row.column.title}
</span>
</div>
{:else}
<button
onclick={() => handleCardClick(row.card)}
class="flex items-center gap-2 px-3 w-full text-left hover:bg-gray-50 dark:hover:bg-gray-800/50 border-b border-gray-50 dark:border-gray-800 transition-colors"
style="height: {ROW_HEIGHT}px;"
title={row.card.title}
>
{#if cardPrefix && row.card.cardNumber}
<span class="text-[10px] font-mono text-gray-400 dark:text-gray-500 flex-shrink-0">{cardPrefix}-{row.card.cardNumber}</span>
{/if}
<span class="text-sm text-gray-700 dark:text-gray-300 truncate">{row.card.title}</span>
</button>
{/if}
{/each}
</div>
</div>
<!-- Timeline (right panel) -->
<div
class="flex-1 overflow-auto"
bind:this={timelineEl}
onscroll={onTimelineScroll}
>
<!-- Sticky header -->
<div class="sticky top-0 z-10" style="width: {totalWidth}px;">
<!-- Month row -->
<div class="flex h-6 bg-gray-50 dark:bg-gray-800/50 border-b border-gray-100 dark:border-gray-700/50">
{#each monthHeaders as mh}
<div
class="text-[10px] font-semibold text-gray-500 dark:text-gray-400 px-2 flex items-center border-r border-gray-200 dark:border-gray-700"
style="position: absolute; left: {mh.x}px; width: {mh.width}px;"
>
{mh.label}
</div>
{/each}
</div>
<!-- Day row -->
<div class="flex h-6 bg-gray-50 dark:bg-gray-800/50 border-b border-gray-200 dark:border-gray-700 relative">
{#each days as day, i}
<div
class="flex-shrink-0 flex items-center justify-center text-[9px] border-r border-gray-100 dark:border-gray-800 {isWeekend(day) ? 'bg-gray-100/50 dark:bg-gray-700/20' : ''}"
style="width: {pxPerDay}px;"
>
{#if zoom === 'day'}
<span class="text-gray-400 dark:text-gray-500">{format(day, 'd')}</span>
{:else if zoom === 'week' && day.getDay() === 1}
<span class="text-gray-400 dark:text-gray-500">{format(day, 'd')}</span>
{/if}
</div>
{/each}
</div>
</div>
<!-- Chart body -->
<svg
width={totalWidth}
height={rows.length * ROW_HEIGHT}
class="block"
>
<!-- Weekend stripes -->
{#each days as day, i}
{#if isWeekend(day)}
<rect
x={i * pxPerDay}
y={0}
width={pxPerDay}
height={rows.length * ROW_HEIGHT}
class="fill-gray-50 dark:fill-gray-800/30"
/>
{/if}
{/each}
<!-- Row gridlines -->
{#each rows as row, i}
<line
x1={0}
y1={(i + 1) * ROW_HEIGHT}
x2={totalWidth}
y2={(i + 1) * ROW_HEIGHT}
class="stroke-gray-100 dark:stroke-gray-800"
stroke-width="1"
/>
{#if row.type === 'column'}
<rect
x={0}
y={i * ROW_HEIGHT}
width={totalWidth}
height={ROW_HEIGHT}
class="fill-gray-50/50 dark:fill-gray-800/20"
/>
{/if}
{/each}
<!-- Today marker -->
{#if todayOffset !== null}
<line
x1={todayOffset}
y1={0}
x2={todayOffset}
y2={rows.length * ROW_HEIGHT}
stroke="var(--color-primary, #3b82f6)"
stroke-width="2"
stroke-dasharray="4 4"
opacity="0.6"
/>
{/if}
<!-- Dependency arrows -->
<defs>
<marker
id="arrowhead"
markerWidth="8"
markerHeight="6"
refX="8"
refY="3"
orient="auto"
>
<polygon points="0 0, 8 3, 0 6" class="fill-gray-400 dark:fill-gray-500" />
</marker>
</defs>
{#each dependencyPaths as path}
<path
d={path.d}
fill="none"
class="stroke-gray-400 dark:stroke-gray-500"
stroke-width="1.5"
marker-end="url(#arrowhead)"
/>
{/each}
<!-- Bars -->
{#each rows as row, i}
{#if row.type === 'card'}
{@const bar = getBarInfo(row.card)}
{@const color = getColumnColor(row.columnIndex)}
{#if bar}
{#if bar.type === 'bar'}
<!-- Full bar: start to due -->
<g
class="cursor-pointer"
onclick={() => handleCardClick(row.card)}
role="button"
tabindex="0"
onkeydown={(e) => { if (e.key === 'Enter') handleCardClick(row.card); }}
>
<rect
x={bar.x}
y={i * ROW_HEIGHT + BAR_Y_OFFSET}
width={bar.width}
height={BAR_HEIGHT}
rx={4}
ry={4}
fill={color}
opacity="0.85"
/>
{#if bar.width > 40}
<text
x={bar.x + 6}
y={i * ROW_HEIGHT + BAR_Y_OFFSET + BAR_HEIGHT / 2}
dominant-baseline="central"
class="text-[10px] fill-white font-medium pointer-events-none"
clip-path="inset(0)"
>
{row.card.title.length > Math.floor(bar.width / 6) ? row.card.title.slice(0, Math.floor(bar.width / 6) - 2) + '...' : row.card.title}
</text>
{/if}
</g>
{:else if bar.type === 'diamond'}
<!-- Diamond: due date only -->
<g
class="cursor-pointer"
onclick={() => handleCardClick(row.card)}
role="button"
tabindex="0"
onkeydown={(e) => { if (e.key === 'Enter') handleCardClick(row.card); }}
>
<polygon
points="{bar.x},{i * ROW_HEIGHT + ROW_HEIGHT / 2 - DIAMOND_SIZE} {bar.x + DIAMOND_SIZE},{i * ROW_HEIGHT + ROW_HEIGHT / 2} {bar.x},{i * ROW_HEIGHT + ROW_HEIGHT / 2 + DIAMOND_SIZE} {bar.x - DIAMOND_SIZE},{i * ROW_HEIGHT + ROW_HEIGHT / 2}"
fill={color}
opacity="0.85"
/>
</g>
{:else if bar.type === 'dashed'}
<!-- Dashed bar: start date only (to today) -->
<g
class="cursor-pointer"
onclick={() => handleCardClick(row.card)}
role="button"
tabindex="0"
onkeydown={(e) => { if (e.key === 'Enter') handleCardClick(row.card); }}
>
<rect
x={bar.x}
y={i * ROW_HEIGHT + BAR_Y_OFFSET}
width={bar.width}
height={BAR_HEIGHT}
rx={4}
ry={4}
fill={color}
opacity="0.3"
stroke={color}
stroke-width="1.5"
stroke-dasharray="4 3"
/>
</g>
{/if}
{/if}
{/if}
{/each}
</svg>
</div>
</div>
</div>
+96
View File
@@ -0,0 +1,96 @@
<script lang="ts">
import { api } from '$lib/utils/api.js';
type Props = {
value: string | null;
canEdit: boolean;
boardId: string;
columnId: string | undefined;
cardId: string;
onupdate: (startDate: string | null) => void;
};
let { value, canEdit, boardId, columnId, cardId, onupdate }: Props = $props();
let picking = $state(false);
let saving = $state(false);
const formatted = $derived(value ? new Date(value).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }) : '');
const dateValue = $derived(value ? new Date(value).toISOString().split('T')[0] : '');
async function setDate(dateStr: string) {
saving = true;
const startDate = dateStr || null;
const { ok } = await api(`/api/boards/${boardId}/columns/${columnId}/cards/${cardId}`, {
method: 'PATCH',
body: JSON.stringify({ startDate })
});
if (ok) {
onupdate(startDate);
}
picking = false;
saving = false;
}
async function clearDate() {
await setDate('');
}
</script>
<div>
<h3 class="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase mb-2">Start Date</h3>
{#if picking && canEdit}
<div class="flex items-center gap-2">
<input
type="date"
value={dateValue}
onchange={(e) => setDate(e.currentTarget.value)}
class="rounded border border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 px-2 py-1 text-sm"
disabled={saving}
/>
<button
onclick={() => (picking = false)}
class="text-xs text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300"
>
Cancel
</button>
</div>
{:else if value}
<div class="flex items-center gap-2">
<span class="inline-flex items-center rounded px-2 py-0.5 text-xs font-medium bg-blue-50 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300">
{formatted}
</span>
{#if canEdit}
<button
onclick={() => (picking = true)}
class="text-xs text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300"
title="Change date"
aria-label="Change start date"
>
<svg class="w-3.5 h-3.5" viewBox="0 0 20 20" fill="currentColor">
<path d="M2.695 14.763l-1.262 3.154a.5.5 0 00.65.65l3.155-1.262a4 4 0 001.343-.885L17.5 5.5a2.121 2.121 0 00-3-3L3.58 13.42a4 4 0 00-.885 1.343z" />
</svg>
</button>
<button
onclick={clearDate}
class="text-xs text-gray-400 hover:text-red-500 dark:text-gray-500"
title="Remove date"
aria-label="Remove start date"
>
<svg class="w-3.5 h-3.5" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd" />
</svg>
</button>
{/if}
</div>
{:else if canEdit}
<button
onclick={() => (picking = true)}
class="text-xs text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300"
>
+ Set start date
</button>
{:else}
<p class="text-xs text-gray-400 dark:text-gray-500 italic">No start date</p>
{/if}
</div>
+5
View File
@@ -83,3 +83,8 @@ export const checklistItemToggleSchema = z.object({
export const commentSchema = z.object({
content: z.string().min(1, 'Content is required').max(10000)
});
export const cardDependencySchema = z.object({
blockingCardId: z.string().uuid(),
blockedCardId: z.string().uuid()
});
@@ -131,6 +131,9 @@ export const PATCH: RequestHandler = async ({ params, request, locals }) => {
updateData.dueDate = body.dueDate ? new Date(body.dueDate) : null;
updateData.dueReminderSent = false;
}
if (body.startDate !== undefined) {
updateData.startDate = body.startDate ? new Date(body.startDate) : null;
}
if (body.archived !== undefined) updateData.archived = body.archived;
const updated = await tx.card.update({
@@ -0,0 +1,172 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types.js';
import { cardDependencySchema } from '$lib/server/validation.js';
import { withTenant } from '$lib/server/rls.js';
import { requireBoardEditor } from '$lib/server/guards.js';
import { broadcast } from '$lib/server/realtime.js';
import { logActivity } from '$lib/server/activity.js';
// Get all dependencies for cards on this board
export const GET: RequestHandler = async ({ params, locals }) => {
const tenant = locals.tenant;
if (!tenant) throw error(400, 'Unknown tenant');
const dependencies = await withTenant(tenant.id, async (tx) => {
return tx.cardDependency.findMany({
where: {
blockingCard: {
column: { board: { id: params.boardId, tenantId: tenant.id } }
}
},
include: {
blockingCard: { select: { id: true, title: true } },
blockedCard: { select: { id: true, title: true } }
}
});
});
return json({ dependencies });
};
// Create a dependency
export const POST: RequestHandler = async ({ params, request, locals }) => {
const tenant = locals.tenant;
if (!tenant) throw error(400, 'Unknown tenant');
const body = await request.json();
const result = cardDependencySchema.safeParse(body);
if (!result.success) {
throw error(400, result.error.issues[0].message);
}
const { blockingCardId, blockedCardId } = result.data;
if (blockingCardId === blockedCardId) {
throw error(400, 'A card cannot depend on itself');
}
const dependency = await withTenant(tenant.id, async (tx) => {
await requireBoardEditor(tx, locals.user, params.boardId, tenant.id);
// Verify both cards belong to this board
const blockingCard = await tx.card.findFirst({
where: { id: blockingCardId, column: { board: { id: params.boardId } } },
select: { id: true, title: true }
});
const blockedCard = await tx.card.findFirst({
where: { id: blockedCardId, column: { board: { id: params.boardId } } },
select: { id: true, title: true }
});
if (!blockingCard || !blockedCard) {
throw error(400, 'Both cards must belong to this board');
}
// Check for existing dependency
const existing = await tx.cardDependency.findUnique({
where: { blockingCardId_blockedCardId: { blockingCardId, blockedCardId } }
});
if (existing) {
throw error(400, 'This dependency already exists');
}
// Cycle detection via DFS
const hasCycle = await detectCycle(tx, blockingCardId, blockedCardId);
if (hasCycle) {
throw error(400, 'Adding this dependency would create a circular dependency');
}
const dep = await tx.cardDependency.create({
data: { blockingCardId, blockedCardId },
include: {
blockingCard: { select: { id: true, title: true } },
blockedCard: { select: { id: true, title: true } }
}
});
await logActivity(tx, {
boardId: params.boardId,
userId: locals.user!.id,
action: 'dependency.created',
cardId: blockedCardId,
metadata: { blockingCardTitle: blockingCard.title, blockedCardTitle: blockedCard.title }
});
return dep;
});
const socketId = request.headers.get('X-Socket-ID');
broadcast(params.boardId, 'card:updated', { cardId: blockingCardId, socketId });
broadcast(params.boardId, 'card:updated', { cardId: blockedCardId, socketId });
return json({ dependency }, { status: 201 });
};
// Delete a dependency
export const DELETE: RequestHandler = async ({ params, request, locals }) => {
const tenant = locals.tenant;
if (!tenant) throw error(400, 'Unknown tenant');
const body = await request.json();
const { id } = body;
if (!id) throw error(400, 'Dependency id is required');
const deleted = await withTenant(tenant.id, async (tx) => {
await requireBoardEditor(tx, locals.user, params.boardId, tenant.id);
const dep = await tx.cardDependency.findUnique({
where: { id },
include: {
blockingCard: { select: { id: true, title: true, column: { select: { board: { select: { id: true, tenantId: true } } } } } },
blockedCard: { select: { id: true, title: true } }
}
});
if (!dep) throw error(404, 'Dependency not found');
if (dep.blockingCard.column.board.id !== params.boardId) {
throw error(404, 'Dependency not found');
}
await tx.cardDependency.delete({ where: { id } });
await logActivity(tx, {
boardId: params.boardId,
userId: locals.user!.id,
action: 'dependency.removed',
cardId: dep.blockedCardId,
metadata: { blockingCardTitle: dep.blockingCard.title, blockedCardTitle: dep.blockedCard.title }
});
return dep;
});
const socketId = request.headers.get('X-Socket-ID');
broadcast(params.boardId, 'card:updated', { cardId: deleted.blockingCardId, socketId });
broadcast(params.boardId, 'card:updated', { cardId: deleted.blockedCardId, socketId });
return json({ ok: true });
};
// DFS cycle detection: would adding blockingCardId -> blockedCardId create a cycle?
// A cycle exists if we can reach blockingCardId by following dependencies from blockedCardId
async function detectCycle(tx: any, blockingCardId: string, blockedCardId: string): Promise<boolean> {
const visited = new Set<string>();
const stack = [blockedCardId];
while (stack.length > 0) {
const current = stack.pop()!;
if (current === blockingCardId) return true;
if (visited.has(current)) continue;
visited.add(current);
// Find all cards that `current` blocks
const deps = await tx.cardDependency.findMany({
where: { blockingCardId: current },
select: { blockedCardId: true }
});
for (const dep of deps) {
stack.push(dep.blockedCardId);
}
}
return false;
}
+6
View File
@@ -487,6 +487,12 @@
>
Calendar
</a>
<a
href="/boards/{data.board.id}/gantt"
class="px-3 py-1 text-xs rounded-md text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300"
>
Gantt
</a>
</div>
<span class="rounded bg-gray-100 px-2 py-0.5 text-xs text-gray-500 dark:bg-gray-800 dark:text-gray-400">
{data.board.visibility === 'PUBLIC' ? 'Public' : 'Private'}
@@ -28,6 +28,12 @@
<span class="px-3 py-1 text-sm rounded-md bg-white dark:bg-gray-700 shadow-sm font-medium text-gray-900 dark:text-gray-100">
Calendar
</span>
<a
href="/boards/{data.board.id}/gantt"
class="px-3 py-1 text-sm rounded-md text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300"
>
Gantt
</a>
</div>
</div>
@@ -0,0 +1,64 @@
import { error } from '@sveltejs/kit';
import type { PageServerLoad } from './$types.js';
import { withTenant } from '$lib/server/rls.js';
import { requireBoardView } from '$lib/server/guards.js';
import { canEditBoard } from '$lib/server/permissions.js';
export const load: PageServerLoad = async ({ params, locals }) => {
const tenant = locals.tenant;
if (!tenant) throw error(400, 'Unknown tenant');
const result = await withTenant(tenant.id, async (tx) => {
const board = await requireBoardView(tx, locals.user, params.boardId, tenant.id);
const dependencies = await tx.cardDependency.findMany({
where: {
blockingCard: {
column: { board: { id: params.boardId, tenantId: tenant.id } }
}
},
select: {
id: true,
blockingCardId: true,
blockedCardId: true
}
});
return { board, dependencies };
});
// Build cards with column metadata, filter to those with startDate or dueDate
const cards = result.board.columns.flatMap((col: any) =>
col.cards
.filter((c: any) => c.startDate || c.dueDate)
.map((c: any) => ({
id: c.id,
title: c.title,
startDate: c.startDate,
dueDate: c.dueDate,
cardNumber: c.cardNumber,
columnId: col.id,
columnTitle: col.title,
assignees: c.assignees,
labels: c.labels
}))
);
// Column metadata for grouping
const columns = result.board.columns.map((col: any) => ({
id: col.id,
title: col.title
}));
return {
board: {
id: result.board.id,
title: result.board.title,
cardPrefix: (result.board as any).cardPrefix
},
cards,
columns,
dependencies: result.dependencies,
canEdit: canEditBoard(locals.user, result.board.members)
};
};
@@ -0,0 +1,107 @@
<script lang="ts">
import { onMount } from 'svelte';
import { joinBoard, leaveBoard, onBoardEvent, offBoardEvent } from '$lib/stores/socket.js';
import GanttChart from '$lib/components/GanttChart.svelte';
let { data } = $props();
let cards = $state(data.cards);
let dependencies = $state(data.dependencies);
onMount(() => {
joinBoard(data.board.id);
function onCardUpdated(payload: any) {
if (payload.card) {
const c = payload.card;
const existing = cards.findIndex((card: any) => card.id === c.id);
if (existing >= 0) {
if (c.startDate || c.dueDate) {
cards[existing] = { ...cards[existing], ...c };
} else {
cards = cards.filter((_: any, i: number) => i !== existing);
}
} else if (c.startDate || c.dueDate) {
cards = [...cards, c];
}
}
}
function onCardCreated(payload: any) {
if (payload.card && (payload.card.startDate || payload.card.dueDate)) {
cards = [...cards, payload.card];
}
}
function onCardDeleted(payload: any) {
cards = cards.filter((c: any) => c.id !== payload.cardId);
dependencies = dependencies.filter(
(d: any) => d.blockingCardId !== payload.cardId && d.blockedCardId !== payload.cardId
);
}
onBoardEvent('card:updated', onCardUpdated);
onBoardEvent('card:created', onCardCreated);
onBoardEvent('card:deleted', onCardDeleted);
return () => {
offBoardEvent('card:updated', onCardUpdated);
offBoardEvent('card:created', onCardCreated);
offBoardEvent('card:deleted', onCardDeleted);
leaveBoard(data.board.id);
};
});
</script>
<svelte:head>
<title>{data.board.title} - Gantt</title>
</svelte:head>
<div class="flex flex-col h-[calc(100vh-3.5rem)]">
<!-- Header -->
<div class="flex items-center gap-3 px-4 py-3 bg-white/80 border-b border-gray-200 dark:bg-gray-900/80 dark:border-gray-700 flex-wrap">
<a href="/boards/{data.board.id}" class="text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300 transition" aria-label="Back to board">
<svg class="w-5 h-5" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M17 10a.75.75 0 01-.75.75H5.612l4.158 3.96a.75.75 0 11-1.04 1.08l-5.5-5.25a.75.75 0 010-1.08l5.5-5.25a.75.75 0 111.04 1.08L5.612 9.25H16.25A.75.75 0 0117 10z" clip-rule="evenodd" />
</svg>
</a>
<h1 class="text-lg font-bold text-gray-900 dark:text-gray-100">{data.board.title}</h1>
<div class="flex gap-1 bg-gray-100 dark:bg-gray-800 rounded-lg p-0.5">
<a
href="/boards/{data.board.id}"
class="px-3 py-1 text-sm rounded-md text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300"
>
Board
</a>
<a
href="/boards/{data.board.id}/calendar"
class="px-3 py-1 text-sm rounded-md text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300"
>
Calendar
</a>
<span class="px-3 py-1 text-sm rounded-md bg-white dark:bg-gray-700 shadow-sm font-medium text-gray-900 dark:text-gray-100">
Gantt
</span>
</div>
</div>
<!-- Gantt Chart -->
<div class="flex-1 overflow-hidden">
{#if cards.length === 0}
<div class="text-center py-12">
<p class="text-gray-400 dark:text-gray-500">No cards with dates to display.</p>
<p class="text-sm text-gray-400 dark:text-gray-500 mt-1">Set start dates or due dates on cards to see them here.</p>
<a href="/boards/{data.board.id}" class="text-sm text-[var(--color-primary)] hover:underline mt-2 inline-block">Back to board</a>
</div>
{:else}
<GanttChart
{cards}
columns={data.columns}
{dependencies}
boardId={data.board.id}
cardPrefix={data.board.cardPrefix}
/>
{/if}
</div>
</div>