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:
@@ -165,6 +165,7 @@ model Card {
|
|||||||
description String?
|
description String?
|
||||||
position String // Fractional index (lexicographic)
|
position String // Fractional index (lexicographic)
|
||||||
dueDate DateTime? @map("due_date")
|
dueDate DateTime? @map("due_date")
|
||||||
|
startDate DateTime? @map("start_date")
|
||||||
archived Boolean @default(false)
|
archived Boolean @default(false)
|
||||||
dueReminderSent Boolean @default(false) @map("due_reminder_sent")
|
dueReminderSent Boolean @default(false) @map("due_reminder_sent")
|
||||||
cardNumber Int? @map("card_number")
|
cardNumber Int? @map("card_number")
|
||||||
@@ -180,6 +181,8 @@ model Card {
|
|||||||
activities Activity[]
|
activities Activity[]
|
||||||
watchers CardWatcher[]
|
watchers CardWatcher[]
|
||||||
customFieldValues CustomFieldValue[]
|
customFieldValues CustomFieldValue[]
|
||||||
|
blocks CardDependency[] @relation("CardBlocks")
|
||||||
|
blockedBy CardDependency[] @relation("CardBlockedBy")
|
||||||
|
|
||||||
@@map("cards")
|
@@map("cards")
|
||||||
}
|
}
|
||||||
@@ -458,6 +461,21 @@ model AutomationRule {
|
|||||||
@@map("automation_rules")
|
@@map("automation_rules")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Card Dependencies ──────────────────────────────────────
|
||||||
|
|
||||||
|
model CardDependency {
|
||||||
|
id String @id @default(uuid()) @db.Uuid
|
||||||
|
blockingCardId String @map("blocking_card_id") @db.Uuid
|
||||||
|
blockedCardId String @map("blocked_card_id") @db.Uuid
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
|
||||||
|
blockingCard Card @relation("CardBlocks", fields: [blockingCardId], references: [id], onDelete: Cascade)
|
||||||
|
blockedCard Card @relation("CardBlockedBy", fields: [blockedCardId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([blockingCardId, blockedCardId])
|
||||||
|
@@map("card_dependencies")
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Tenant Branding & Theming ───────────────────────────────
|
// ─── Tenant Branding & Theming ───────────────────────────────
|
||||||
|
|
||||||
model TenantTheme {
|
model TenantTheme {
|
||||||
|
|||||||
@@ -78,6 +78,9 @@ ALTER TABLE custom_field_values FORCE ROW LEVEL SECURITY;
|
|||||||
ALTER TABLE automation_rules ENABLE ROW LEVEL SECURITY;
|
ALTER TABLE automation_rules ENABLE ROW LEVEL SECURITY;
|
||||||
ALTER TABLE automation_rules FORCE ROW LEVEL SECURITY;
|
ALTER TABLE automation_rules FORCE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
ALTER TABLE card_dependencies ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE card_dependencies FORCE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
-- ─── Tenant-scoped tables (direct tenant_id) ──────────
|
-- ─── Tenant-scoped tables (direct tenant_id) ──────────
|
||||||
|
|
||||||
-- Users
|
-- Users
|
||||||
@@ -460,6 +463,28 @@ CREATE POLICY automation_rules_tenant_isolation ON automation_rules
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
-- Card Dependencies (via blocking_card_id -> card -> column -> board)
|
||||||
|
DROP POLICY IF EXISTS card_dependencies_tenant_isolation ON card_dependencies;
|
||||||
|
CREATE POLICY card_dependencies_tenant_isolation ON card_dependencies
|
||||||
|
USING (
|
||||||
|
current_setting('app.current_tenant_id', true) = '__bypass__'
|
||||||
|
OR blocking_card_id IN (
|
||||||
|
SELECT ca.id FROM cards ca
|
||||||
|
JOIN columns c ON ca.column_id = c.id
|
||||||
|
JOIN boards b ON c.board_id = b.id
|
||||||
|
WHERE b.tenant_id::text = current_setting('app.current_tenant_id', true)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
WITH CHECK (
|
||||||
|
current_setting('app.current_tenant_id', true) = '__bypass__'
|
||||||
|
OR blocking_card_id IN (
|
||||||
|
SELECT ca.id FROM cards ca
|
||||||
|
JOIN columns c ON ca.column_id = c.id
|
||||||
|
JOIN boards b ON c.board_id = b.id
|
||||||
|
WHERE b.tenant_id::text = current_setting('app.current_tenant_id', true)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
-- ─── User-scoped tables ───────────────────────────────
|
-- ─── User-scoped tables ───────────────────────────────
|
||||||
|
|
||||||
-- Notifications
|
-- Notifications
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { trapFocus } from '$lib/utils/focus-trap.js';
|
import { trapFocus } from '$lib/utils/focus-trap.js';
|
||||||
import MarkdownEditor from './MarkdownEditor.svelte';
|
import MarkdownEditor from './MarkdownEditor.svelte';
|
||||||
|
import StartDatePicker from './StartDatePicker.svelte';
|
||||||
import DueDatePicker from './DueDatePicker.svelte';
|
import DueDatePicker from './DueDatePicker.svelte';
|
||||||
import TagPicker from './TagPicker.svelte';
|
import TagPicker from './TagPicker.svelte';
|
||||||
import AssigneePicker from './AssigneePicker.svelte';
|
import AssigneePicker from './AssigneePicker.svelte';
|
||||||
@@ -10,6 +11,7 @@
|
|||||||
import AttachmentList from './AttachmentList.svelte';
|
import AttachmentList from './AttachmentList.svelte';
|
||||||
import ActivityFeed from './ActivityFeed.svelte';
|
import ActivityFeed from './ActivityFeed.svelte';
|
||||||
import CustomFieldsSection from './CustomFieldsSection.svelte';
|
import CustomFieldsSection from './CustomFieldsSection.svelte';
|
||||||
|
import DependencySection from './DependencySection.svelte';
|
||||||
import { api } from '$lib/utils/api.js';
|
import { api } from '$lib/utils/api.js';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@@ -91,6 +93,12 @@
|
|||||||
onupdate(card);
|
onupdate(card);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function updateStartDate(startDate: string | null) {
|
||||||
|
card.startDate = startDate;
|
||||||
|
if (fullCard) fullCard.startDate = startDate;
|
||||||
|
onupdate(card);
|
||||||
|
}
|
||||||
|
|
||||||
function updateDueDate(dueDate: string | null) {
|
function updateDueDate(dueDate: string | null) {
|
||||||
card.dueDate = dueDate;
|
card.dueDate = dueDate;
|
||||||
if (fullCard) fullCard.dueDate = dueDate;
|
if (fullCard) fullCard.dueDate = dueDate;
|
||||||
@@ -275,6 +283,15 @@
|
|||||||
|
|
||||||
<!-- Sidebar -->
|
<!-- Sidebar -->
|
||||||
<div class="w-full md:w-48 space-y-4 flex-shrink-0">
|
<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
|
<DueDatePicker
|
||||||
value={card.dueDate || null}
|
value={card.dueDate || null}
|
||||||
{canEdit}
|
{canEdit}
|
||||||
@@ -294,6 +311,12 @@
|
|||||||
onupdate={updateAssignees}
|
onupdate={updateAssignees}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<DependencySection
|
||||||
|
cardId={card.id}
|
||||||
|
{boardId}
|
||||||
|
{canEdit}
|
||||||
|
/>
|
||||||
|
|
||||||
<!-- Actions -->
|
<!-- Actions -->
|
||||||
{#if canEdit}
|
{#if canEdit}
|
||||||
<div>
|
<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({
|
export const commentSchema = z.object({
|
||||||
content: z.string().min(1, 'Content is required').max(10000)
|
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.dueDate = body.dueDate ? new Date(body.dueDate) : null;
|
||||||
updateData.dueReminderSent = false;
|
updateData.dueReminderSent = false;
|
||||||
}
|
}
|
||||||
|
if (body.startDate !== undefined) {
|
||||||
|
updateData.startDate = body.startDate ? new Date(body.startDate) : null;
|
||||||
|
}
|
||||||
if (body.archived !== undefined) updateData.archived = body.archived;
|
if (body.archived !== undefined) updateData.archived = body.archived;
|
||||||
|
|
||||||
const updated = await tx.card.update({
|
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
|
Calendar
|
||||||
</a>
|
</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>
|
</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">
|
<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'}
|
{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">
|
<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
|
Calendar
|
||||||
</span>
|
</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>
|
||||||
</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