diff --git a/src/lib/components/DependencySection.svelte b/src/lib/components/DependencySection.svelte
new file mode 100644
index 0000000..9520f9e
--- /dev/null
+++ b/src/lib/components/DependencySection.svelte
@@ -0,0 +1,201 @@
+
+
+
+
Dependencies
+
+ {#if loading}
+
Loading...
+ {:else}
+
+ {#if blocks.length > 0}
+
+
Blocks
+ {#each blocks as dep}
+
+
{dep.blockedCard.title}
+ {#if canEdit}
+
+ {/if}
+
+ {/each}
+
+ {/if}
+
+
+ {#if blockedBy.length > 0}
+
+
Blocked by
+ {#each blockedBy as dep}
+
+
{dep.blockingCard.title}
+ {#if canEdit}
+
+ {/if}
+
+ {/each}
+
+ {/if}
+
+
+ {#if canEdit}
+ {#if adding}
+
+
+ {adding === 'blocks' ? 'This card blocks...' : 'This card is blocked by...'}
+
+
+ {#if searchResults.length > 0}
+
+ {#each searchResults as result}
+
+ {/each}
+
+ {:else if searchQuery && !searching}
+
No cards found
+ {/if}
+
+
+ {:else}
+
+
+
+
+ {/if}
+ {/if}
+ {/if}
+
diff --git a/src/lib/components/GanttChart.svelte b/src/lib/components/GanttChart.svelte
new file mode 100644
index 0000000..c789200
--- /dev/null
+++ b/src/lib/components/GanttChart.svelte
@@ -0,0 +1,513 @@
+
+
+
+
+
+
Zoom:
+
+
+
+
+
+
{cards.length} card{cards.length !== 1 ? 's' : ''}
+
+
+
+
+
+
+
+
+ Tasks
+
+
+
+ {#each rows as row, i}
+ {#if row.type === 'column'}
+
+
+ {row.column.title}
+
+
+ {:else}
+
+ {/if}
+ {/each}
+
+
+
+
+
+
+
+
+
+ {#each monthHeaders as mh}
+
+ {mh.label}
+
+ {/each}
+
+
+
+ {#each days as day, i}
+
+ {#if zoom === 'day'}
+ {format(day, 'd')}
+ {:else if zoom === 'week' && day.getDay() === 1}
+ {format(day, 'd')}
+ {/if}
+
+ {/each}
+
+
+
+
+
+
+
+
diff --git a/src/lib/components/StartDatePicker.svelte b/src/lib/components/StartDatePicker.svelte
new file mode 100644
index 0000000..c931165
--- /dev/null
+++ b/src/lib/components/StartDatePicker.svelte
@@ -0,0 +1,96 @@
+
+
+
+
Start Date
+ {#if picking && canEdit}
+
+ 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}
+ />
+
+
+ {:else if value}
+
+
+ {formatted}
+
+ {#if canEdit}
+
+
+ {/if}
+
+ {:else if canEdit}
+
+ {:else}
+
No start date
+ {/if}
+
diff --git a/src/lib/server/validation.ts b/src/lib/server/validation.ts
index 1cb1941..d75757c 100644
--- a/src/lib/server/validation.ts
+++ b/src/lib/server/validation.ts
@@ -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()
+});
diff --git a/src/routes/api/boards/[boardId]/columns/[columnId]/cards/[cardId]/+server.ts b/src/routes/api/boards/[boardId]/columns/[columnId]/cards/[cardId]/+server.ts
index e75f98f..db7378f 100644
--- a/src/routes/api/boards/[boardId]/columns/[columnId]/cards/[cardId]/+server.ts
+++ b/src/routes/api/boards/[boardId]/columns/[columnId]/cards/[cardId]/+server.ts
@@ -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({
diff --git a/src/routes/api/boards/[boardId]/dependencies/+server.ts b/src/routes/api/boards/[boardId]/dependencies/+server.ts
new file mode 100644
index 0000000..08b066b
--- /dev/null
+++ b/src/routes/api/boards/[boardId]/dependencies/+server.ts
@@ -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
{
+ const visited = new Set();
+ 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;
+}
diff --git a/src/routes/boards/[boardId]/+page.svelte b/src/routes/boards/[boardId]/+page.svelte
index c5dfa21..08190ff 100644
--- a/src/routes/boards/[boardId]/+page.svelte
+++ b/src/routes/boards/[boardId]/+page.svelte
@@ -487,6 +487,12 @@
>
Calendar
+
+ Gantt
+