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:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user