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
@@ -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;
}