Files
openpylon/docs/plans/2026-02-16-15-improvements-implementation.md
Your Name 8dedbf6032 docs: add 15-improvements design doc and implementation plan
Also tracks Cargo.lock and BoardCardOverlay component from prior sessions.
2026-02-16 14:56:22 +02:00

2475 lines
69 KiB
Markdown

# 15 Improvements Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Implement 15 improvements to OpenPylon kanban app across 5 phases, from data model changes through infrastructure features.
**Architecture:** All features follow existing patterns: Zustand store with `mutate(get, set, (b) => ...)` for board mutations, Zod schemas with `.default()` for backwards-compatible data evolution, Radix UI for menus/dialogs, Framer Motion for animations, OKLCH colors, Tailwind CSS 4.
**Tech Stack:** Tauri v2, React 19, TypeScript, Zustand 5 (with zundo temporal), Tailwind CSS 4, Radix UI, dnd-kit, Framer Motion, date-fns, ulid
**Note:** This project has no test framework. Verification is done by running `npm run tauri dev` and manually testing each feature.
---
## Phase 0: Data Model Foundation
All type/schema changes that later features depend on.
---
### Task 1: Add Comment type and schema
**Files:**
- Modify: `src/types/board.ts`
- Modify: `src/lib/schemas.ts`
**Step 1: Add Comment interface to types**
In `src/types/board.ts`, add after the `ChecklistItem` interface (line 46):
```typescript
export interface Comment {
id: string;
text: string;
createdAt: string;
}
```
**Step 2: Add commentSchema to schemas**
In `src/lib/schemas.ts`, add after `checklistItemSchema` (line 7):
```typescript
export const commentSchema = z.object({
id: z.string(),
text: z.string(),
createdAt: z.string(),
});
```
**Step 3: Verify**
Run: `npx tsc --noEmit`
Expected: No errors
**Step 4: Commit**
```
git add src/types/board.ts src/lib/schemas.ts
git commit -m "feat: add Comment type and schema"
```
---
### Task 2: Add priority and comments fields to Card
**Files:**
- Modify: `src/types/board.ts`
- Modify: `src/lib/schemas.ts`
**Step 1: Add Priority type and update Card interface**
In `src/types/board.ts`, add before the `Card` interface:
```typescript
export type Priority = "none" | "low" | "medium" | "high" | "urgent";
```
Add two fields to the `Card` interface (after `coverColor`):
```typescript
priority: Priority;
comments: Comment[];
```
**Step 2: Add fields to cardSchema**
In `src/lib/schemas.ts`, add to `cardSchema` (after `coverColor` line):
```typescript
priority: z.enum(["none", "low", "medium", "high", "urgent"]).default("none"),
comments: z.array(commentSchema).default([]),
```
**Step 3: Update addCard in board-store.ts**
In `src/stores/board-store.ts`, update the `addCard` action's card creation (around line 198-209) to include new fields:
```typescript
const card: Card = {
id: cardId,
title,
description: "",
labels: [],
checklist: [],
dueDate: null,
attachments: [],
coverColor: null,
priority: "none",
comments: [],
createdAt: now(),
updatedAt: now(),
};
```
Also update the import from `@/types/board` to include `Priority`.
**Step 4: Verify**
Run: `npx tsc --noEmit`
Expected: No errors
**Step 5: Commit**
```
git add src/types/board.ts src/lib/schemas.ts src/stores/board-store.ts
git commit -m "feat: add priority and comments fields to Card"
```
---
### Task 3: Add collapsed and wipLimit fields to Column
**Files:**
- Modify: `src/types/board.ts`
- Modify: `src/lib/schemas.ts`
**Step 1: Update Column interface**
In `src/types/board.ts`, add two fields to the `Column` interface (after `color`):
```typescript
collapsed: boolean;
wipLimit: number | null;
```
**Step 2: Update columnSchema**
In `src/lib/schemas.ts`, add to `columnSchema` (after `color` line):
```typescript
collapsed: z.boolean().default(false),
wipLimit: z.number().nullable().default(null),
```
**Step 3: Update addColumn in board-store.ts**
In `src/stores/board-store.ts`, update the column creation in `addColumn` (around line 130-136) to include new fields:
```typescript
{
id: ulid(),
title,
cardIds: [],
width: "standard" as ColumnWidth,
color: null,
collapsed: false,
wipLimit: null,
}
```
**Step 4: Update board-factory.ts**
In `src/lib/board-factory.ts`, update the `col` helper (around line 24-30) to include new fields:
```typescript
const col = (t: string, w: ColumnWidth = "standard") => ({
id: ulid(),
title: t,
cardIds: [] as string[],
width: w,
color: null as string | null,
collapsed: false,
wipLimit: null as number | null,
});
```
**Step 5: Verify**
Run: `npx tsc --noEmit`
Expected: No errors
**Step 6: Commit**
```
git add src/types/board.ts src/lib/schemas.ts src/stores/board-store.ts src/lib/board-factory.ts
git commit -m "feat: add collapsed and wipLimit fields to Column"
```
---
## Phase 1: Quick Wins
Minimal changes, high value.
---
### Task 4: #8 — Consume defaultColumnWidth setting
**Files:**
- Modify: `src/stores/board-store.ts`
**Step 1: Read setting in addColumn**
In `src/stores/board-store.ts`, update `addColumn` to read the setting. Replace the hardcoded `"standard"`:
```typescript
addColumn: (title: string) => {
const defaultWidth = useAppStore.getState().settings.defaultColumnWidth;
mutate(get, set, (b) => ({
...b,
updatedAt: now(),
columns: [
...b.columns,
{
id: ulid(),
title,
cardIds: [],
width: defaultWidth,
color: null,
collapsed: false,
wipLimit: null,
},
],
}));
},
```
Add import at top of file:
```typescript
import { useAppStore } from "@/stores/app-store";
```
**Step 2: Verify**
Run `npm run tauri dev`. Change default column width in Settings, add a new column. It should use the selected width.
**Step 3: Commit**
```
git add src/stores/board-store.ts
git commit -m "feat: addColumn reads defaultColumnWidth from settings"
```
---
### Task 5: #4 — Due date visual indicators
**Files:**
- Modify: `src/components/board/CardThumbnail.tsx`
**Step 1: Add getDueDateStatus helper and update rendering**
In `src/components/board/CardThumbnail.tsx`, replace the existing due date logic (lines 36-38) and the due date rendering in the footer (lines 109-119).
Add this helper function before the `CardThumbnail` component:
```typescript
function getDueDateStatus(dueDate: string | null): { color: string; bgColor: string; label: string } | null {
if (!dueDate) return null;
const date = new Date(dueDate);
const now = new Date();
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const dueDay = new Date(date.getFullYear(), date.getMonth(), date.getDate());
const diffDays = Math.ceil((dueDay.getTime() - today.getTime()) / (1000 * 60 * 60 * 24));
if (diffDays < 0) {
return { color: "text-pylon-danger", bgColor: "bg-pylon-danger/10", label: "Overdue" };
}
if (diffDays <= 2) {
return { color: "text-[oklch(65%_0.15_70)]", bgColor: "bg-[oklch(65%_0.15_70/10%)]", label: "Due soon" };
}
return { color: "text-[oklch(55%_0.12_145)]", bgColor: "bg-[oklch(55%_0.12_145/10%)]", label: "Upcoming" };
}
```
Remove the old `overdue` variable and `isPast`/`isToday` imports (keep `format` from date-fns). Replace the due date span in the footer row:
```typescript
{card.dueDate && (() => {
const status = getDueDateStatus(card.dueDate);
if (!status) return null;
return (
<span
className={`font-mono text-xs rounded px-1 py-0.5 ${status.color} ${status.bgColor}`}
title={status.label}
>
{format(new Date(card.dueDate), "MMM d")}
</span>
);
})()}
```
**Step 2: Clean up imports**
Remove `isPast, isToday` from the date-fns import since they're no longer needed.
**Step 3: Verify**
Run `npm run tauri dev`. Create cards with due dates: past dates should be red, dates within 2 days should be amber, dates further out should be green.
**Step 4: Commit**
```
git add src/components/board/CardThumbnail.tsx
git commit -m "feat: color-coded due date indicators (red/amber/green)"
```
---
### Task 6: #9 — Card aging visualization
**Files:**
- Modify: `src/components/board/CardThumbnail.tsx`
**Step 1: Add aging opacity helper**
Add this helper near `getDueDateStatus`:
```typescript
function getAgingOpacity(updatedAt: string): number {
const days = (Date.now() - new Date(updatedAt).getTime()) / (1000 * 60 * 60 * 24);
if (days <= 7) return 1.0;
if (days <= 14) return 0.85;
if (days <= 30) return 0.7;
return 0.55;
}
```
**Step 2: Apply opacity to card**
In the `motion.button` element, add `opacity` to the `style` prop:
```typescript
style={{
transform: CSS.Transform.toString(transform),
transition,
padding: `calc(0.75rem * var(--density-factor))`,
opacity: getAgingOpacity(card.updatedAt),
}}
```
**Step 3: Verify**
Run `npm run tauri dev`. Cards updated recently should be fully opaque. Old cards should appear faded.
**Step 4: Commit**
```
git add src/components/board/CardThumbnail.tsx
git commit -m "feat: card aging visualization - stale cards fade"
```
---
### Task 7: #12 — Open attachments
**Files:**
- Modify: `src/components/card-detail/AttachmentSection.tsx`
**Step 1: Add Open button to each attachment**
Import the opener and add an icon:
```typescript
import { openPath } from "@tauri-apps/plugin-opener";
import { FileIcon, X, Plus, ExternalLink } from "lucide-react";
```
In the attachment row (inside the `.map`), add an open button before the remove button:
```typescript
<button
onClick={() => openPath(att.path)}
className="shrink-0 rounded p-0.5 text-pylon-text-secondary opacity-0 transition-opacity hover:bg-pylon-accent/10 hover:text-pylon-accent group-hover/att:opacity-100"
aria-label="Open attachment"
>
<ExternalLink className="size-3" />
</button>
```
**Step 2: Verify**
Run `npm run tauri dev`. Add an attachment to a card. The "Open" icon should appear on hover and open the file with the system default application.
**Step 3: Commit**
```
git add src/components/card-detail/AttachmentSection.tsx
git commit -m "feat: open attachments with system default app"
```
---
## Phase 2: Card Interactions & UI Enhancements
---
### Task 8: #2 — Card priority levels (thumbnail indicator)
**Files:**
- Modify: `src/components/board/CardThumbnail.tsx`
**Step 1: Add priority color map and dot**
Add constant near top of file:
```typescript
const PRIORITY_COLORS: Record<string, string> = {
low: "oklch(60% 0.15 240)", // blue
medium: "oklch(70% 0.15 85)", // yellow
high: "oklch(60% 0.15 55)", // orange
urgent: "oklch(55% 0.15 25)", // red
};
```
In the footer row (the `div` with `mt-2 flex items-center gap-3`), add priority dot at the start:
```typescript
{card.priority !== "none" && (
<span
className={`size-2.5 shrink-0 rounded-full ${card.priority === "urgent" ? "animate-pulse" : ""}`}
style={{ backgroundColor: PRIORITY_COLORS[card.priority] }}
title={`Priority: ${card.priority}`}
/>
)}
```
Also update the footer row's show condition to include priority:
```typescript
{(hasDueDate || card.checklist.length > 0 || card.attachments.length > 0 || card.description || card.priority !== "none") && (
```
**Step 2: Verify**
Run `npm run tauri dev`. Set priorities on cards (we'll add the picker in the next task). For now verify with no TypeScript errors: `npx tsc --noEmit`.
**Step 3: Commit**
```
git add src/components/board/CardThumbnail.tsx
git commit -m "feat: priority dot indicator on card thumbnails"
```
---
### Task 9: #2 — Card priority levels (detail modal picker)
**Files:**
- Create: `src/components/card-detail/PriorityPicker.tsx`
- Modify: `src/components/card-detail/CardDetailModal.tsx`
**Step 1: Create PriorityPicker component**
Create `src/components/card-detail/PriorityPicker.tsx`:
```typescript
import { useBoardStore } from "@/stores/board-store";
import type { Priority } from "@/types/board";
const PRIORITIES: { value: Priority; label: string; color: string }[] = [
{ value: "none", label: "None", color: "oklch(50% 0 0 / 30%)" },
{ value: "low", label: "Low", color: "oklch(60% 0.15 240)" },
{ value: "medium", label: "Medium", color: "oklch(70% 0.15 85)" },
{ value: "high", label: "High", color: "oklch(60% 0.15 55)" },
{ value: "urgent", label: "Urgent", color: "oklch(55% 0.15 25)" },
];
interface PriorityPickerProps {
cardId: string;
priority: Priority;
}
export function PriorityPicker({ cardId, priority }: PriorityPickerProps) {
const updateCard = useBoardStore((s) => s.updateCard);
return (
<div className="flex flex-col gap-2">
<h4 className="font-mono text-xs uppercase tracking-widest text-pylon-text-secondary">
Priority
</h4>
<div className="flex flex-wrap gap-1.5">
{PRIORITIES.map(({ value, label, color }) => (
<button
key={value}
onClick={() => updateCard(cardId, { priority: value })}
className={`rounded-full px-3 py-1 text-xs font-medium transition-all ${
priority === value
? "ring-2 ring-offset-1 ring-offset-pylon-column/50 text-white"
: "text-pylon-text-secondary hover:text-pylon-text"
}`}
style={{
backgroundColor: priority === value ? color : undefined,
borderColor: color,
border: priority !== value ? `1px solid ${color}` : undefined,
ringColor: color,
}}
>
{label}
</button>
))}
</div>
</div>
);
}
```
**Step 2: Add PriorityPicker to CardDetailModal**
In `src/components/card-detail/CardDetailModal.tsx`, import the new component:
```typescript
import { PriorityPicker } from "@/components/card-detail/PriorityPicker";
```
Add a new grid cell for Priority. Insert it in the dashboard grid — add it as a new row before the Cover color section. Replace the Row 3 comment block (Cover + Attachments) with:
```typescript
{/* Row 3: Priority + Cover */}
<motion.div
className="rounded-lg bg-pylon-column/50 p-4"
variants={fadeSlideUp}
transition={springs.bouncy}
>
<PriorityPicker cardId={cardId} priority={card.priority} />
</motion.div>
<motion.div
className="rounded-lg bg-pylon-column/50 p-4"
variants={fadeSlideUp}
transition={springs.bouncy}
>
<CoverColorPicker
cardId={cardId}
coverColor={card.coverColor}
/>
</motion.div>
{/* Row 4: Attachments (full width) */}
<motion.div
className="col-span-2 rounded-lg bg-pylon-column/50 p-4"
variants={fadeSlideUp}
transition={springs.bouncy}
>
<AttachmentSection
cardId={cardId}
attachments={card.attachments}
/>
</motion.div>
```
**Step 3: Verify**
Run `npm run tauri dev`. Open a card detail. Priority picker should show 5 chips. Click one — the card thumbnail should show the corresponding colored dot.
**Step 4: Commit**
```
git add src/components/card-detail/PriorityPicker.tsx src/components/card-detail/CardDetailModal.tsx
git commit -m "feat: priority picker in card detail modal"
```
---
### Task 10: #5 — Card context menu
**Files:**
- Modify: `src/components/board/CardThumbnail.tsx`
- Modify: `src/stores/board-store.ts`
**Step 1: Add duplicateCard store action**
In `src/stores/board-store.ts`, add to `BoardActions` interface:
```typescript
duplicateCard: (cardId: string) => string | null;
```
Add implementation after `deleteCard`:
```typescript
duplicateCard: (cardId) => {
const { board } = get();
if (!board) return null;
const original = board.cards[cardId];
if (!original) return null;
const column = board.columns.find((c) => c.cardIds.includes(cardId));
if (!column) return null;
const newId = ulid();
const ts = now();
const clone: Card = {
...original,
id: newId,
title: `${original.title} (copy)`,
comments: [],
createdAt: ts,
updatedAt: ts,
};
const insertIndex = column.cardIds.indexOf(cardId) + 1;
mutate(get, set, (b) => ({
...b,
updatedAt: ts,
cards: { ...b.cards, [newId]: clone },
columns: b.columns.map((c) =>
c.id === column.id
? {
...c,
cardIds: [
...c.cardIds.slice(0, insertIndex),
newId,
...c.cardIds.slice(insertIndex),
],
}
: c
),
}));
return newId;
},
```
**Step 2: Wrap CardThumbnail in ContextMenu**
In `src/components/board/CardThumbnail.tsx`, add imports:
```typescript
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
} from "@/components/ui/context-menu";
import { useBoardStore } from "@/stores/board-store";
import type { Priority } from "@/types/board";
```
Wrap the `motion.button` in a `ContextMenu`. The return for the non-dragging case becomes:
```typescript
return (
<ContextMenu>
<ContextMenuTrigger asChild>
<motion.button ...> {/* existing content */} </motion.button>
</ContextMenuTrigger>
<CardContextMenuContent cardId={card.id} columnId={columnId} />
</ContextMenu>
);
```
Add a `CardContextMenuContent` component (can be inline in the same file):
```typescript
function CardContextMenuContent({ cardId, columnId }: { cardId: string; columnId: string }) {
const board = useBoardStore((s) => s.board);
const moveCard = useBoardStore((s) => s.moveCard);
const updateCard = useBoardStore((s) => s.updateCard);
const duplicateCard = useBoardStore((s) => s.duplicateCard);
const deleteCard = useBoardStore((s) => s.deleteCard);
if (!board) return null;
const otherColumns = board.columns.filter((c) => c.id !== columnId);
const priorities: { value: Priority; label: string }[] = [
{ value: "none", label: "None" },
{ value: "low", label: "Low" },
{ value: "medium", label: "Medium" },
{ value: "high", label: "High" },
{ value: "urgent", label: "Urgent" },
];
return (
<ContextMenuContent>
{otherColumns.length > 0 && (
<ContextMenuSub>
<ContextMenuSubTrigger>Move to</ContextMenuSubTrigger>
<ContextMenuSubContent>
{otherColumns.map((col) => (
<ContextMenuItem
key={col.id}
onClick={() => moveCard(cardId, columnId, col.id, col.cardIds.length)}
>
{col.title}
</ContextMenuItem>
))}
</ContextMenuSubContent>
</ContextMenuSub>
)}
<ContextMenuSub>
<ContextMenuSubTrigger>Set priority</ContextMenuSubTrigger>
<ContextMenuSubContent>
{priorities.map(({ value, label }) => (
<ContextMenuItem
key={value}
onClick={() => updateCard(cardId, { priority: value })}
>
{label}
</ContextMenuItem>
))}
</ContextMenuSubContent>
</ContextMenuSub>
<ContextMenuItem onClick={() => duplicateCard(cardId)}>
Duplicate
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem
variant="destructive"
onClick={() => deleteCard(cardId)}
>
Delete
</ContextMenuItem>
</ContextMenuContent>
);
}
```
Also add `import { ContextMenuTrigger } from "@/components/ui/context-menu";` to the imports if not already there.
**Step 3: Verify**
Run `npm run tauri dev`. Right-click a card. Context menu should show: Move to (submenu), Set priority (submenu), Duplicate, Delete.
**Step 4: Commit**
```
git add src/components/board/CardThumbnail.tsx src/stores/board-store.ts
git commit -m "feat: card context menu with move, priority, duplicate, delete"
```
---
### Task 11: #10 — WIP limits
**Files:**
- Modify: `src/stores/board-store.ts`
- Modify: `src/components/board/ColumnHeader.tsx`
- Modify: `src/components/board/KanbanColumn.tsx`
**Step 1: Add setColumnWipLimit store action**
In `src/stores/board-store.ts`, add to `BoardActions`:
```typescript
setColumnWipLimit: (columnId: string, limit: number | null) => void;
```
Add implementation after `setColumnColor`:
```typescript
setColumnWipLimit: (columnId, limit) => {
mutate(get, set, (b) => ({
...b,
updatedAt: now(),
columns: b.columns.map((c) =>
c.id === columnId ? { ...c, wipLimit: limit } : c
),
}));
},
```
**Step 2: Update ColumnHeader with WIP limit menu item**
In `src/components/board/ColumnHeader.tsx`, add the store action:
```typescript
const setColumnWipLimit = useBoardStore((s) => s.setColumnWipLimit);
```
Add a WIP Limit submenu in the dropdown, after the Color submenu:
```typescript
<DropdownMenuSub>
<DropdownMenuSubTrigger>WIP Limit</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<DropdownMenuRadioGroup
value={column.wipLimit?.toString() ?? "none"}
onValueChange={(v) => setColumnWipLimit(column.id, v === "none" ? null : parseInt(v))}
>
<DropdownMenuRadioItem value="none">None</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="3">3</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="5">5</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="7">7</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="10">10</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
</DropdownMenuSubContent>
</DropdownMenuSub>
```
**Step 3: Update ColumnHeader card count display**
Replace the card count `<span>` to show WIP status:
```typescript
<span className={`shrink-0 font-mono text-xs ${
column.wipLimit != null && cardCount > column.wipLimit
? "text-pylon-danger font-bold"
: column.wipLimit != null && cardCount === column.wipLimit
? "text-[oklch(65%_0.15_70)]"
: "text-pylon-text-secondary"
}`}>
{cardCount}{column.wipLimit != null ? `/${column.wipLimit}` : ""}
</span>
```
**Step 4: Update KanbanColumn with WIP tint**
In `src/components/board/KanbanColumn.tsx`, compute a background tint based on WIP limit status. Add after `const cardCount`:
```typescript
const wipTint = column.wipLimit != null
? cardCount > column.wipLimit
? "oklch(70% 0.08 25 / 15%)" // red tint - over limit
: cardCount === column.wipLimit
? "oklch(75% 0.08 70 / 15%)" // amber tint - at limit
: undefined
: undefined;
```
Apply it to the `motion.section` as an additional background style. Update the `style` prop on the `motion.section`:
```typescript
style={{ borderTop, backgroundColor: wipTint }}
```
**Step 5: Verify**
Run `npm run tauri dev`. Set a WIP limit on a column. Add cards to exceed it. The header count should change color and the column should get a tinted background.
**Step 6: Commit**
```
git add src/stores/board-store.ts src/components/board/ColumnHeader.tsx src/components/board/KanbanColumn.tsx
git commit -m "feat: WIP limits with visual indicators"
```
---
### Task 12: #3 — Column collapse/expand
**Files:**
- Modify: `src/stores/board-store.ts`
- Modify: `src/components/board/ColumnHeader.tsx`
- Modify: `src/components/board/KanbanColumn.tsx`
**Step 1: Add toggleColumnCollapse store action**
In `src/stores/board-store.ts`, add to `BoardActions`:
```typescript
toggleColumnCollapse: (columnId: string) => void;
```
Add implementation:
```typescript
toggleColumnCollapse: (columnId) => {
mutate(get, set, (b) => ({
...b,
updatedAt: now(),
columns: b.columns.map((c) =>
c.id === columnId ? { ...c, collapsed: !c.collapsed } : c
),
}));
},
```
**Step 2: Add Collapse menu item to ColumnHeader**
In `src/components/board/ColumnHeader.tsx`, add the store action:
```typescript
const toggleColumnCollapse = useBoardStore((s) => s.toggleColumnCollapse);
```
Add menu item in the dropdown, after the Rename item:
```typescript
<DropdownMenuItem onClick={() => toggleColumnCollapse(column.id)}>
Collapse
</DropdownMenuItem>
```
**Step 3: Render collapsed state in KanbanColumn**
In `src/components/board/KanbanColumn.tsx`, add the store action:
```typescript
const toggleColumnCollapse = useBoardStore((s) => s.toggleColumnCollapse);
```
Add import for `ChevronRight` from lucide-react.
Inside the outer `motion.div`, before the `motion.section`, add a collapsed view. The logic: if `column.collapsed`, render the narrow strip instead of the full column. Update the `animate` on the outer `motion.div`:
```typescript
animate={{ width: column.collapsed ? 40 : width, opacity: 1 }}
```
Then wrap the `motion.section` in a conditional. If collapsed, show:
```typescript
{column.collapsed ? (
<button
onClick={() => toggleColumnCollapse(column.id)}
className="flex h-full w-full flex-col items-center gap-2 rounded-lg bg-pylon-column py-3 hover:bg-pylon-column/80 transition-colors"
style={{ borderTop }}
>
<ChevronRight className="size-3.5 text-pylon-text-secondary" />
<span
className="font-mono text-xs font-semibold uppercase tracking-widest text-pylon-text-secondary"
style={{ writingMode: "vertical-rl", transform: "rotate(180deg)" }}
>
{column.title}
</span>
<span className="mt-auto font-mono text-xs text-pylon-text-secondary">
{cardCount}
</span>
</button>
) : (
<motion.section ...> {/* existing full column content */} </motion.section>
)}
```
**Step 4: Verify**
Run `npm run tauri dev`. Use the column header dropdown to collapse a column. It should shrink to a 40px strip with vertical text. Click the strip to expand.
**Step 5: Commit**
```
git add src/stores/board-store.ts src/components/board/ColumnHeader.tsx src/components/board/KanbanColumn.tsx
git commit -m "feat: column collapse/expand with animated transition"
```
---
### Task 13: #11 — Checklist item reordering
**Files:**
- Modify: `src/stores/board-store.ts`
- Modify: `src/components/card-detail/ChecklistSection.tsx`
**Step 1: Add reorderChecklistItems store action**
In `src/stores/board-store.ts`, add to `BoardActions`:
```typescript
reorderChecklistItems: (cardId: string, fromIndex: number, toIndex: number) => void;
```
Add implementation:
```typescript
reorderChecklistItems: (cardId, fromIndex, toIndex) => {
mutate(get, set, (b) => {
const card = b.cards[cardId];
if (!card) return b;
const items = [...card.checklist];
const [moved] = items.splice(fromIndex, 1);
items.splice(toIndex, 0, moved);
return {
...b,
updatedAt: now(),
cards: {
...b.cards,
[cardId]: { ...card, checklist: items, updatedAt: now() },
},
};
});
},
```
**Step 2: Add dnd-kit to ChecklistSection**
In `src/components/card-detail/ChecklistSection.tsx`, add imports:
```typescript
import {
DndContext,
closestCenter,
PointerSensor,
useSensor,
useSensors,
type DragEndEvent,
} from "@dnd-kit/core";
import {
SortableContext,
verticalListSortingStrategy,
useSortable,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { GripVertical, X } from "lucide-react";
```
Add store action:
```typescript
const reorderChecklistItems = useBoardStore((s) => s.reorderChecklistItems);
```
Add sensors:
```typescript
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 3 } })
);
```
Add drag handler:
```typescript
function handleDragEnd(event: DragEndEvent) {
const { active, over } = event;
if (!over || active.id === over.id) return;
const oldIndex = checklist.findIndex((item) => item.id === active.id);
const newIndex = checklist.findIndex((item) => item.id === over.id);
if (oldIndex !== -1 && newIndex !== -1) {
reorderChecklistItems(cardId, oldIndex, newIndex);
}
}
```
Wrap the checklist items `div` in DndContext + SortableContext:
```typescript
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext items={checklist.map((item) => item.id)} strategy={verticalListSortingStrategy}>
<div className="flex flex-col gap-1">
{checklist.map((item) => (
<ChecklistRow ... />
))}
</div>
</SortableContext>
</DndContext>
```
**Step 3: Make ChecklistRow sortable**
Update `ChecklistRow` to use `useSortable`:
```typescript
function ChecklistRow({ item, onToggle, onUpdate, onDelete }: ChecklistRowProps) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: item.id,
});
// ...existing state...
return (
<div
ref={setNodeRef}
style={{
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : undefined,
}}
className="group/item flex items-center gap-2 rounded px-1 py-0.5 hover:bg-pylon-column/60"
{...attributes}
>
<span
className="shrink-0 cursor-grab text-pylon-text-secondary opacity-0 group-hover/item:opacity-100"
{...listeners}
>
<GripVertical className="size-3" />
</span>
{/* rest of existing content */}
</div>
);
}
```
**Step 4: Verify**
Run `npm run tauri dev`. Open a card with checklist items. Drag items by the grip handle to reorder them.
**Step 5: Commit**
```
git add src/stores/board-store.ts src/components/card-detail/ChecklistSection.tsx
git commit -m "feat: drag-and-drop checklist item reordering"
```
---
## Phase 3: Navigation & Power User Features
---
### Task 14: #1 — Card filtering & quick search
**Files:**
- Create: `src/components/board/FilterBar.tsx`
- Modify: `src/components/board/BoardView.tsx`
- Modify: `src/components/board/KanbanColumn.tsx`
- Modify: `src/components/layout/TopBar.tsx`
**Step 1: Create FilterBar component**
Create `src/components/board/FilterBar.tsx`:
```typescript
import { useState, useCallback, useEffect, useRef } from "react";
import { motion } from "framer-motion";
import { springs } from "@/lib/motion";
import { X, Search } from "lucide-react";
import { Button } from "@/components/ui/button";
import type { Label, Priority } from "@/types/board";
export interface FilterState {
text: string;
labels: string[];
dueDate: "all" | "overdue" | "week" | "today" | "none";
priority: "all" | Priority;
}
export const EMPTY_FILTER: FilterState = {
text: "",
labels: [],
dueDate: "all",
priority: "all",
};
export function isFilterActive(f: FilterState): boolean {
return f.text !== "" || f.labels.length > 0 || f.dueDate !== "all" || f.priority !== "all";
}
interface FilterBarProps {
filters: FilterState;
onChange: (filters: FilterState) => void;
onClose: () => void;
boardLabels: Label[];
}
export function FilterBar({ filters, onChange, onClose, boardLabels }: FilterBarProps) {
const inputRef = useRef<HTMLInputElement>(null);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const [textDraft, setTextDraft] = useState(filters.text);
useEffect(() => {
inputRef.current?.focus();
}, []);
const handleTextChange = useCallback(
(value: string) => {
setTextDraft(value);
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => {
onChange({ ...filters, text: value });
}, 200);
},
[filters, onChange]
);
function toggleLabel(labelId: string) {
const labels = filters.labels.includes(labelId)
? filters.labels.filter((l) => l !== labelId)
: [...filters.labels, labelId];
onChange({ ...filters, labels });
}
function clearAll() {
setTextDraft("");
onChange(EMPTY_FILTER);
}
return (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={springs.snappy}
className="overflow-hidden border-b border-border bg-pylon-surface"
>
<div className="flex items-center gap-3 px-4 py-2">
{/* Text search */}
<div className="flex items-center gap-1.5 rounded-md bg-pylon-column px-2 py-1">
<Search className="size-3.5 text-pylon-text-secondary" />
<input
ref={inputRef}
value={textDraft}
onChange={(e) => handleTextChange(e.target.value)}
placeholder="Search cards..."
className="h-5 w-40 bg-transparent text-sm text-pylon-text outline-none placeholder:text-pylon-text-secondary/60"
/>
</div>
{/* Label filter chips */}
{boardLabels.length > 0 && (
<div className="flex items-center gap-1">
{boardLabels.map((label) => (
<button
key={label.id}
onClick={() => toggleLabel(label.id)}
className={`rounded-full px-2 py-0.5 text-xs transition-all ${
filters.labels.includes(label.id)
? "text-white"
: "opacity-40 hover:opacity-70"
}`}
style={{ backgroundColor: label.color }}
>
{label.name}
</button>
))}
</div>
)}
{/* Due date filter */}
<select
value={filters.dueDate}
onChange={(e) => onChange({ ...filters, dueDate: e.target.value as FilterState["dueDate"] })}
className="h-6 rounded-md bg-pylon-column px-2 text-xs text-pylon-text outline-none"
>
<option value="all">All dates</option>
<option value="overdue">Overdue</option>
<option value="week">Due this week</option>
<option value="today">Due today</option>
<option value="none">No date</option>
</select>
{/* Priority filter */}
<select
value={filters.priority}
onChange={(e) => onChange({ ...filters, priority: e.target.value as FilterState["priority"] })}
className="h-6 rounded-md bg-pylon-column px-2 text-xs text-pylon-text outline-none"
>
<option value="all">All priorities</option>
<option value="urgent">Urgent</option>
<option value="high">High</option>
<option value="medium">Medium</option>
<option value="low">Low</option>
<option value="none">No priority</option>
</select>
{/* Spacer + clear + close */}
<div className="flex-1" />
{isFilterActive(filters) && (
<Button variant="ghost" size="sm" onClick={clearAll} className="text-xs text-pylon-text-secondary">
Clear all
</Button>
)}
<Button variant="ghost" size="icon-xs" onClick={onClose} className="text-pylon-text-secondary hover:text-pylon-text">
<X className="size-3.5" />
</Button>
</div>
</motion.div>
);
}
```
**Step 2: Add filter logic to BoardView**
In `src/components/board/BoardView.tsx`, import and wire the filter bar:
```typescript
import { AnimatePresence } from "framer-motion";
import { FilterBar, type FilterState, EMPTY_FILTER, isFilterActive } from "@/components/board/FilterBar";
```
Add filter state:
```typescript
const [showFilterBar, setShowFilterBar] = useState(false);
const [filters, setFilters] = useState<FilterState>(EMPTY_FILTER);
```
Add `filterCards` helper inside the component:
```typescript
function filterCards(cardIds: string[]): string[] {
if (!isFilterActive(filters) || !board) return cardIds;
return cardIds.filter((id) => {
const card = board.cards[id];
if (!card) return false;
if (filters.text && !card.title.toLowerCase().includes(filters.text.toLowerCase())) return false;
if (filters.labels.length > 0 && !filters.labels.some((l) => card.labels.includes(l))) return false;
if (filters.priority !== "all" && card.priority !== filters.priority) return false;
if (filters.dueDate !== "all") {
const now = new Date();
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
if (filters.dueDate === "none" && card.dueDate != null) return false;
if (filters.dueDate === "overdue" && (!card.dueDate || new Date(card.dueDate) >= today)) return false;
if (filters.dueDate === "today") {
if (!card.dueDate) return false;
const d = new Date(card.dueDate);
if (d.toDateString() !== today.toDateString()) return false;
}
if (filters.dueDate === "week") {
if (!card.dueDate) return false;
const d = new Date(card.dueDate);
const weekEnd = new Date(today);
weekEnd.setDate(weekEnd.getDate() + 7);
if (d < today || d > weekEnd) return false;
}
}
return true;
});
}
```
Add keyboard shortcut for `/`:
```typescript
useEffect(() => {
function handleKey(e: KeyboardEvent) {
if (e.key === "/" && !e.ctrlKey && !e.metaKey) {
const tag = (e.target as HTMLElement).tagName;
if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return;
e.preventDefault();
setShowFilterBar(true);
}
}
document.addEventListener("keydown", handleKey);
return () => document.removeEventListener("keydown", handleKey);
}, []);
```
Render FilterBar above the DndContext, inside the main fragment:
```typescript
<AnimatePresence>
{showFilterBar && board && (
<FilterBar
filters={filters}
onChange={setFilters}
onClose={() => { setShowFilterBar(false); setFilters(EMPTY_FILTER); }}
boardLabels={board.labels}
/>
)}
</AnimatePresence>
```
Pass `filteredCardIds` to `KanbanColumn`:
```typescript
<KanbanColumn
key={column.id}
column={column}
filteredCardIds={isFilterActive(filters) ? filterCards(column.cardIds) : undefined}
onCardClick={setSelectedCardId}
isNew={!initialColumnIds.current?.has(column.id)}
/>
```
**Step 3: Update KanbanColumn to accept filteredCardIds**
In `src/components/board/KanbanColumn.tsx`, add `filteredCardIds` prop:
```typescript
interface KanbanColumnProps {
column: Column;
filteredCardIds?: string[];
onCardClick?: (cardId: string) => void;
isNew?: boolean;
}
```
Use it when rendering cards:
```typescript
const displayCardIds = filteredCardIds ?? column.cardIds;
const isFiltering = filteredCardIds != null;
```
Update the card count display and the card rendering to use `displayCardIds`. If filtering, show `"3 of 7"` style count in the column header area.
**Step 4: Add filter button to TopBar**
In `src/components/layout/TopBar.tsx`, add a Filter button next to the board settings button. Import `Filter` from lucide-react.
Add button that dispatches a custom event:
```typescript
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon-sm"
className="text-pylon-text-secondary hover:text-pylon-text"
onClick={() => document.dispatchEvent(new CustomEvent("toggle-filter-bar"))}
>
<Filter className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
Filter cards <kbd className="ml-1 font-mono text-[10px] opacity-60">/</kbd>
</TooltipContent>
</Tooltip>
```
In BoardView, listen for this event:
```typescript
useEffect(() => {
function handleToggleFilter() {
setShowFilterBar((prev) => !prev);
}
document.addEventListener("toggle-filter-bar", handleToggleFilter);
return () => document.removeEventListener("toggle-filter-bar", handleToggleFilter);
}, []);
```
**Step 5: Verify**
Run `npm run tauri dev`. Press `/` or click the filter button. The filter bar should slide down. Type to search, click labels to filter, use dropdowns. Cards should filter in real-time.
**Step 6: Commit**
```
git add src/components/board/FilterBar.tsx src/components/board/BoardView.tsx src/components/board/KanbanColumn.tsx src/components/layout/TopBar.tsx
git commit -m "feat: card filtering and quick search with filter bar"
```
---
### Task 15: #7 — Keyboard card navigation
**Files:**
- Create: `src/hooks/useKeyboardNavigation.ts`
- Modify: `src/components/board/BoardView.tsx`
- Modify: `src/components/board/KanbanColumn.tsx`
- Modify: `src/components/board/CardThumbnail.tsx`
**Step 1: Create useKeyboardNavigation hook**
Create `src/hooks/useKeyboardNavigation.ts`:
```typescript
import { useState, useEffect, useCallback } from "react";
import type { Board } from "@/types/board";
export function useKeyboardNavigation(
board: Board | null,
onOpenCard: (cardId: string) => void
) {
const [focusedCardId, setFocusedCardId] = useState<string | null>(null);
const handleKeyDown = useCallback(
(e: KeyboardEvent) => {
if (!board) return;
const tag = (e.target as HTMLElement).tagName;
if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return;
const key = e.key.toLowerCase();
const isNav = ["j", "k", "h", "l", "arrowdown", "arrowup", "arrowleft", "arrowright", "enter", "escape"].includes(key);
if (!isNav) return;
e.preventDefault();
if (key === "escape") {
setFocusedCardId(null);
return;
}
if (key === "enter" && focusedCardId) {
onOpenCard(focusedCardId);
return;
}
// Build navigation grid
const columns = board.columns.filter((c) => !c.collapsed && c.cardIds.length > 0);
if (columns.length === 0) return;
// Find current position
let colIdx = -1;
let cardIdx = -1;
if (focusedCardId) {
for (let ci = 0; ci < columns.length; ci++) {
const idx = columns[ci].cardIds.indexOf(focusedCardId);
if (idx !== -1) {
colIdx = ci;
cardIdx = idx;
break;
}
}
}
// If nothing focused, focus first card
if (colIdx === -1) {
setFocusedCardId(columns[0].cardIds[0]);
return;
}
if (key === "j" || key === "arrowdown") {
const col = columns[colIdx];
const next = Math.min(cardIdx + 1, col.cardIds.length - 1);
setFocusedCardId(col.cardIds[next]);
} else if (key === "k" || key === "arrowup") {
const col = columns[colIdx];
const next = Math.max(cardIdx - 1, 0);
setFocusedCardId(col.cardIds[next]);
} else if (key === "l" || key === "arrowright") {
const nextCol = Math.min(colIdx + 1, columns.length - 1);
const targetIdx = Math.min(cardIdx, columns[nextCol].cardIds.length - 1);
setFocusedCardId(columns[nextCol].cardIds[targetIdx]);
} else if (key === "h" || key === "arrowleft") {
const prevCol = Math.max(colIdx - 1, 0);
const targetIdx = Math.min(cardIdx, columns[prevCol].cardIds.length - 1);
setFocusedCardId(columns[prevCol].cardIds[targetIdx]);
}
},
[board, focusedCardId, onOpenCard]
);
useEffect(() => {
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [handleKeyDown]);
// Clear focus when a card is removed
useEffect(() => {
if (focusedCardId && board && !board.cards[focusedCardId]) {
setFocusedCardId(null);
}
}, [board, focusedCardId]);
return { focusedCardId, setFocusedCardId };
}
```
**Step 2: Wire hook into BoardView**
In `src/components/board/BoardView.tsx`:
```typescript
import { useKeyboardNavigation } from "@/hooks/useKeyboardNavigation";
// Inside BoardView:
const { focusedCardId, setFocusedCardId } = useKeyboardNavigation(board, setSelectedCardId);
```
Pass `focusedCardId` to `KanbanColumn`:
```typescript
<KanbanColumn
...
focusedCardId={focusedCardId}
/>
```
Clear focus when opening a card (in `setSelectedCardId`):
```typescript
function handleCardClick(cardId: string) {
setSelectedCardId(cardId);
setFocusedCardId(null);
}
```
**Step 3: Pass isFocused through KanbanColumn to CardThumbnail**
In `src/components/board/KanbanColumn.tsx`, add `focusedCardId` prop and pass it through:
```typescript
interface KanbanColumnProps {
column: Column;
filteredCardIds?: string[];
focusedCardId?: string | null;
onCardClick?: (cardId: string) => void;
isNew?: boolean;
}
```
In the card render:
```typescript
<CardThumbnail
card={card}
boardLabels={board?.labels ?? []}
columnId={column.id}
onCardClick={onCardClick}
isFocused={focusedCardId === cardId}
/>
```
**Step 4: Add focus ring to CardThumbnail**
In `src/components/board/CardThumbnail.tsx`, add `isFocused` prop:
```typescript
interface CardThumbnailProps {
card: Card;
boardLabels: Label[];
columnId: string;
onCardClick?: (cardId: string) => void;
isFocused?: boolean;
}
```
Add auto-scroll ref and effect:
```typescript
const cardRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
if (isFocused && cardRef.current) {
cardRef.current.scrollIntoView({ block: "nearest", behavior: "smooth" });
}
}, [isFocused]);
```
Add focus ring class to the `motion.button`:
```typescript
className={`w-full rounded-lg bg-pylon-surface shadow-sm text-left ${
isFocused ? "ring-2 ring-pylon-accent ring-offset-2 ring-offset-pylon-column" : ""
}`}
```
Assign the ref (note: need to merge with sortable ref — use `useCallback` ref or pass to both):
```typescript
ref={(node) => {
setNodeRef(node);
(cardRef as React.MutableRefObject<HTMLButtonElement | null>).current = node;
}}
```
**Step 5: Verify**
Run `npm run tauri dev`. Press `J`/`K` to navigate cards vertically, `H`/`L` for columns. Focused card should have an accent ring. `Enter` opens the card, `Escape` clears focus.
**Step 6: Commit**
```
git add src/hooks/useKeyboardNavigation.ts src/components/board/BoardView.tsx src/components/board/KanbanColumn.tsx src/components/board/CardThumbnail.tsx
git commit -m "feat: keyboard card navigation with J/K/H/L and focus ring"
```
---
### Task 16: #6 — Desktop notifications for due dates
**Files:**
- Modify: `src-tauri/Cargo.toml`
- Modify: `src-tauri/capabilities/default.json`
- Modify: `src/stores/app-store.ts`
- Modify: `src/types/settings.ts`
- Modify: `src/lib/schemas.ts`
**Step 1: Add tauri-plugin-notification**
In `src-tauri/Cargo.toml`, add to `[dependencies]`:
```toml
tauri-plugin-notification = "2"
```
Register the plugin in `src-tauri/src/lib.rs` (find existing `.plugin()` calls and add):
```rust
.plugin(tauri_plugin_notification::init())
```
In `src-tauri/capabilities/default.json`, add to `permissions` array:
```json
"notification:default"
```
Install the npm package:
```bash
npm install @tauri-apps/plugin-notification
```
**Step 2: Add lastNotificationCheck to settings**
In `src/types/settings.ts`, add to `AppSettings`:
```typescript
lastNotificationCheck: string | null;
```
In `src/lib/schemas.ts`, add to `appSettingsSchema`:
```typescript
lastNotificationCheck: z.string().nullable().default(null),
```
In `src/stores/app-store.ts`, add to default settings:
```typescript
lastNotificationCheck: null,
```
**Step 3: Add notification check to init**
In `src/stores/app-store.ts`, import and add notification logic to `init`:
```typescript
import { isPermissionGranted, requestPermission, sendNotification } from "@tauri-apps/plugin-notification";
```
After `set({ settings, boards, initialized: true })` in `init`, add:
```typescript
// Due date notifications (once per hour)
const lastCheck = settings.lastNotificationCheck;
const hourAgo = new Date(Date.now() - 60 * 60 * 1000).toISOString();
if (!lastCheck || lastCheck < hourAgo) {
try {
let granted = await isPermissionGranted();
if (!granted) {
const perm = await requestPermission();
granted = perm === "granted";
}
if (granted) {
// Scan all boards for due cards
let dueToday = 0;
let overdue = 0;
const today = new Date();
const todayStr = today.toDateString();
for (const meta of boards) {
try {
const board = await loadBoard(meta.id);
for (const card of Object.values(board.cards)) {
if (!card.dueDate) continue;
const due = new Date(card.dueDate);
if (due.toDateString() === todayStr) dueToday++;
else if (due < today) overdue++;
}
} catch { /* skip */ }
}
if (dueToday > 0) {
sendNotification({ title: "OpenPylon", body: `You have ${dueToday} card${dueToday > 1 ? "s" : ""} due today` });
}
if (overdue > 0) {
sendNotification({ title: "OpenPylon", body: `You have ${overdue} overdue card${overdue > 1 ? "s" : ""}` });
}
}
updateAndSave(get, set, { lastNotificationCheck: new Date().toISOString() });
} catch { /* notification plugin not available */ }
}
```
Note: `loadBoard` needs to be imported (it should already be accessible — check the import from storage).
**Step 4: Verify**
Run `npm run tauri dev` (requires `cargo build` for the new plugin). With cards that have due dates set to today or in the past, a system notification should appear.
**Step 5: Commit**
```
git add src-tauri/Cargo.toml src-tauri/src/lib.rs src-tauri/capabilities/default.json src/stores/app-store.ts src/types/settings.ts src/lib/schemas.ts package.json package-lock.json
git commit -m "feat: desktop notifications for due/overdue cards"
```
---
### Task 17: #13 — Card comments / activity log
**Files:**
- Create: `src/components/card-detail/CommentsSection.tsx`
- Modify: `src/stores/board-store.ts`
- Modify: `src/components/card-detail/CardDetailModal.tsx`
**Step 1: Add comment store actions**
In `src/stores/board-store.ts`, add to `BoardActions`:
```typescript
addComment: (cardId: string, text: string) => void;
deleteComment: (cardId: string, commentId: string) => void;
```
Add implementations:
```typescript
addComment: (cardId, text) => {
mutate(get, set, (b) => {
const card = b.cards[cardId];
if (!card) return b;
const comment = { id: ulid(), text, createdAt: now() };
return {
...b,
updatedAt: now(),
cards: {
...b.cards,
[cardId]: {
...card,
comments: [comment, ...card.comments],
updatedAt: now(),
},
},
};
});
},
deleteComment: (cardId, commentId) => {
mutate(get, set, (b) => {
const card = b.cards[cardId];
if (!card) return b;
return {
...b,
updatedAt: now(),
cards: {
...b.cards,
[cardId]: {
...card,
comments: card.comments.filter((c) => c.id !== commentId),
updatedAt: now(),
},
},
};
});
},
```
**Step 2: Create CommentsSection component**
Create `src/components/card-detail/CommentsSection.tsx`:
```typescript
import { useState, useRef } from "react";
import { formatDistanceToNow } from "date-fns";
import { X } from "lucide-react";
import { OverlayScrollbarsComponent } from "overlayscrollbars-react";
import { Button } from "@/components/ui/button";
import { useBoardStore } from "@/stores/board-store";
import type { Comment } from "@/types/board";
interface CommentsSectionProps {
cardId: string;
comments: Comment[];
}
export function CommentsSection({ cardId, comments }: CommentsSectionProps) {
const addComment = useBoardStore((s) => s.addComment);
const deleteComment = useBoardStore((s) => s.deleteComment);
const [draft, setDraft] = useState("");
const textareaRef = useRef<HTMLTextAreaElement>(null);
function handleAdd() {
const trimmed = draft.trim();
if (!trimmed) return;
addComment(cardId, trimmed);
setDraft("");
textareaRef.current?.focus();
}
function handleKeyDown(e: React.KeyboardEvent) {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleAdd();
}
}
return (
<div className="flex flex-col gap-2">
<h4 className="font-mono text-xs uppercase tracking-widest text-pylon-text-secondary">
Comments
</h4>
{/* Add comment */}
<div className="flex gap-2">
<textarea
ref={textareaRef}
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Add a comment... (Enter to send, Shift+Enter for newline)"
rows={2}
className="flex-1 resize-none rounded-md bg-pylon-column px-2 py-1.5 text-sm text-pylon-text outline-none placeholder:text-pylon-text-secondary/60 focus:ring-1 focus:ring-pylon-accent"
/>
<Button
size="sm"
onClick={handleAdd}
disabled={!draft.trim()}
className="self-end"
>
Add
</Button>
</div>
{/* Comment list */}
{comments.length > 0 && (
<OverlayScrollbarsComponent
className="max-h-[200px]"
options={{ scrollbars: { theme: "os-theme-pylon", autoHide: "scroll", autoHideDelay: 600, clickScroll: true }, overflow: { x: "hidden" } }}
defer
>
<div className="flex flex-col gap-2">
{comments.map((comment) => (
<div
key={comment.id}
className="group/comment flex gap-2 rounded px-2 py-1.5 hover:bg-pylon-column/60"
>
<div className="flex-1">
<p className="whitespace-pre-wrap text-sm text-pylon-text">
{comment.text}
</p>
<span className="font-mono text-[10px] text-pylon-text-secondary">
{formatDistanceToNow(new Date(comment.createdAt), { addSuffix: true })}
</span>
</div>
<button
onClick={() => deleteComment(cardId, comment.id)}
className="shrink-0 self-start rounded p-0.5 text-pylon-text-secondary opacity-0 transition-opacity hover:bg-pylon-danger/10 hover:text-pylon-danger group-hover/comment:opacity-100"
aria-label="Delete comment"
>
<X className="size-3" />
</button>
</div>
))}
</div>
</OverlayScrollbarsComponent>
)}
</div>
);
}
```
**Step 3: Add CommentsSection to CardDetailModal**
In `src/components/card-detail/CardDetailModal.tsx`, import:
```typescript
import { CommentsSection } from "@/components/card-detail/CommentsSection";
```
Add a new full-width row at the bottom of the grid (after attachments):
```typescript
{/* Row 5: Comments (full width) */}
<motion.div
className="col-span-2 rounded-lg bg-pylon-column/50 p-4"
variants={fadeSlideUp}
transition={springs.bouncy}
>
<CommentsSection cardId={cardId} comments={card.comments} />
</motion.div>
```
**Step 4: Verify**
Run `npm run tauri dev`. Open a card. Comments section should appear at the bottom. Add a comment with Enter, see it appear with relative timestamp. Delete with X on hover.
**Step 5: Commit**
```
git add src/components/card-detail/CommentsSection.tsx src/stores/board-store.ts src/components/card-detail/CardDetailModal.tsx
git commit -m "feat: card comments with add/delete and timestamps"
```
---
## Phase 4: System Features & Infrastructure
---
### Task 18: #14 — Board templates & saved structures
**Files:**
- Create: `src/types/template.ts`
- Modify: `src/lib/storage.ts`
- Modify: `src/lib/board-factory.ts`
- Modify: `src/components/boards/BoardCard.tsx` (add "Save as Template" menu item)
- Modify: `src/components/boards/BoardList.tsx` (update NewBoardDialog to show templates)
**Step 1: Create template type**
Create `src/types/template.ts`:
```typescript
import type { ColumnWidth, Label, BoardSettings } from "./board";
export interface BoardTemplate {
id: string;
name: string;
color: string;
columns: {
title: string;
width: ColumnWidth;
color: string | null;
wipLimit: number | null;
}[];
labels: Label[];
settings: BoardSettings;
}
```
**Step 2: Add template storage functions**
In `src/lib/storage.ts`, add a templates directory helper and CRUD:
```typescript
async function getTemplatesDir(): Promise<string> {
const base = await getBaseDir();
return join(base, "templates");
}
```
Update `ensureDataDirs` to create templates dir:
```typescript
const templatesDir = await getTemplatesDir();
if (!(await exists(templatesDir))) {
await mkdir(templatesDir, { recursive: true });
}
```
Add template functions:
```typescript
export async function listTemplates(): Promise<BoardTemplate[]> {
const dir = await getTemplatesDir();
if (!(await exists(dir))) return [];
const entries = await readDir(dir);
const templates: BoardTemplate[] = [];
for (const entry of entries) {
if (!entry.name || !entry.name.endsWith(".json")) continue;
try {
const filePath = await join(dir, entry.name);
const raw = await readTextFile(filePath);
templates.push(JSON.parse(raw));
} catch { continue; }
}
return templates;
}
export async function saveTemplate(template: BoardTemplate): Promise<void> {
const dir = await getTemplatesDir();
const filePath = await join(dir, `${template.id}.json`);
await writeTextFile(filePath, JSON.stringify(template, null, 2));
}
export async function deleteTemplate(templateId: string): Promise<void> {
const dir = await getTemplatesDir();
const filePath = await join(dir, `${templateId}.json`);
if (await exists(filePath)) {
await remove(filePath);
}
}
```
Import the type:
```typescript
import type { BoardTemplate } from "@/types/template";
```
**Step 3: Add createBoardFromTemplate to board-factory**
In `src/lib/board-factory.ts`:
```typescript
import type { BoardTemplate } from "@/types/template";
export function createBoardFromTemplate(template: BoardTemplate, title: string): Board {
const ts = new Date().toISOString();
return {
id: ulid(),
title,
color: template.color,
createdAt: ts,
updatedAt: ts,
columns: template.columns.map((c) => ({
id: ulid(),
title: c.title,
cardIds: [],
width: c.width,
color: c.color,
collapsed: false,
wipLimit: c.wipLimit,
})),
cards: {},
labels: template.labels.map((l) => ({ ...l, id: ulid() })),
settings: { ...template.settings },
};
}
```
**Step 4: Add "Save as Template" to BoardCard context menu**
In `src/components/boards/BoardCard.tsx`, add a template save handler:
```typescript
import { listTemplates, saveTemplate, loadBoard, saveBoard, deleteBoard } from "@/lib/storage";
import type { BoardTemplate } from "@/types/template";
async function handleSaveAsTemplate() {
const full = await loadBoard(board.id);
const { ulid } = await import("ulid");
const template: BoardTemplate = {
id: ulid(),
name: full.title,
color: full.color,
columns: full.columns.map((c) => ({
title: c.title,
width: c.width,
color: c.color,
wipLimit: c.wipLimit,
})),
labels: full.labels,
settings: full.settings,
};
await saveTemplate(template);
addToast(`Template "${full.title}" saved`, "success");
}
```
Add menu item in the context menu (after Duplicate):
```typescript
<ContextMenuItem onClick={handleSaveAsTemplate}>
<Bookmark className="size-4" />
Save as Template
</ContextMenuItem>
```
Import `Bookmark` from lucide-react.
**Step 5: Update NewBoardDialog to show templates**
The `NewBoardDialog` component needs to be found/updated to load templates from storage and show them as options alongside the built-in Blank/Kanban/Sprint templates. User templates get a delete button. When selected, use `createBoardFromTemplate` instead of `createBoard`.
This involves finding the new board dialog (likely in `BoardList.tsx`) and adding template support. The exact implementation depends on the existing dialog structure — load templates with `useEffect` + `listTemplates()`, display as a grid of clickable options.
**Step 6: Verify**
Run `npm run tauri dev`. Right-click a board card > "Save as Template". Create a new board — the saved template should appear as an option.
**Step 7: Commit**
```
git add src/types/template.ts src/lib/storage.ts src/lib/board-factory.ts src/components/boards/BoardCard.tsx src/components/boards/BoardList.tsx
git commit -m "feat: board templates - save and create from templates"
```
---
### Task 19: #15 — Auto-backup & version history
**Files:**
- Modify: `src/lib/storage.ts`
- Modify: `src/stores/board-store.ts`
- Create: `src/components/board/VersionHistoryDialog.tsx`
- Modify: `src/components/layout/TopBar.tsx`
**Step 1: Add backup storage functions**
In `src/lib/storage.ts`, add:
```typescript
async function getBackupsDir(boardId: string): Promise<string> {
const base = await getBaseDir();
return join(base, "backups", boardId);
}
export interface BackupEntry {
filename: string;
timestamp: string;
cardCount: number;
columnCount: number;
}
export async function listBackups(boardId: string): Promise<BackupEntry[]> {
const dir = await getBackupsDir(boardId);
if (!(await exists(dir))) return [];
const entries = await readDir(dir);
const backups: BackupEntry[] = [];
for (const entry of entries) {
if (!entry.name || !entry.name.endsWith(".json")) continue;
try {
const filePath = await join(dir, entry.name);
const raw = await readTextFile(filePath);
const data = JSON.parse(raw);
const board = boardSchema.parse(data);
// Extract timestamp from filename: {boardId}-{ISO}.json
const isoMatch = entry.name.match(/\d{4}-\d{2}-\d{2}T[\d:.]+Z/);
backups.push({
filename: entry.name,
timestamp: isoMatch ? isoMatch[0] : board.updatedAt,
cardCount: Object.keys(board.cards).length,
columnCount: board.columns.length,
});
} catch { continue; }
}
backups.sort((a, b) => b.timestamp.localeCompare(a.timestamp));
return backups;
}
export async function createBackup(board: Board): Promise<void> {
const dir = await getBackupsDir(board.id);
if (!(await exists(dir))) {
await mkdir(dir, { recursive: true });
}
const ts = new Date().toISOString().replace(/:/g, "-");
const filename = `${board.id}-${ts}.json`;
const filePath = await join(dir, filename);
await writeTextFile(filePath, JSON.stringify(board, null, 2));
}
export async function pruneBackups(boardId: string, keep: number = 10): Promise<void> {
const backups = await listBackups(boardId);
if (backups.length <= keep) return;
const dir = await getBackupsDir(boardId);
const toDelete = backups.slice(keep);
for (const backup of toDelete) {
try {
const filePath = await join(dir, backup.filename);
await remove(filePath);
} catch { /* skip */ }
}
}
export async function restoreBackupFile(boardId: string, filename: string): Promise<Board> {
const dir = await getBackupsDir(boardId);
const filePath = await join(dir, filename);
const raw = await readTextFile(filePath);
const data = JSON.parse(raw);
const board = boardSchema.parse(data) as Board;
return board;
}
```
**Step 2: Integrate auto-backup into saveBoard**
Update `saveBoard` in `src/lib/storage.ts` to create timestamped backups:
```typescript
export async function saveBoard(board: Board): Promise<void> {
const boardsDir = await getBoardsDir();
const filePath = await boardFilePath(boardsDir, board.id);
const backupPath = await boardBackupPath(boardsDir, board.id);
// Rotate previous version to backup
if (await exists(filePath)) {
try {
const previous = await readTextFile(filePath);
await writeTextFile(backupPath, previous);
// Also create a timestamped backup
await createBackup(JSON.parse(previous) as Board);
await pruneBackups(board.id);
} catch {
// If we can't create a backup, continue saving anyway
}
}
await writeTextFile(filePath, JSON.stringify(board, null, 2));
}
```
Note: This will create many backups since `saveBoard` is called on every debounced change. To avoid excessive backups, add a throttle — only create a timestamped backup if the last one is more than 5 minutes old:
```typescript
// Only create timestamped backup if last backup > 5 min ago
const backups = await listBackups(board.id);
const recentThreshold = new Date(Date.now() - 5 * 60 * 1000).toISOString();
if (backups.length === 0 || backups[0].timestamp < recentThreshold) {
await createBackup(JSON.parse(previous) as Board);
await pruneBackups(board.id);
}
```
**Step 3: Create VersionHistoryDialog**
Create `src/components/board/VersionHistoryDialog.tsx`:
```typescript
import { useState, useEffect } from "react";
import { formatDistanceToNow } from "date-fns";
import { OverlayScrollbarsComponent } from "overlayscrollbars-react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { listBackups, restoreBackupFile, saveBoard, type BackupEntry } from "@/lib/storage";
import { useBoardStore } from "@/stores/board-store";
interface VersionHistoryDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function VersionHistoryDialog({ open, onOpenChange }: VersionHistoryDialogProps) {
const board = useBoardStore((s) => s.board);
const [backups, setBackups] = useState<BackupEntry[]>([]);
const [confirmRestore, setConfirmRestore] = useState<BackupEntry | null>(null);
useEffect(() => {
if (open && board) {
listBackups(board.id).then(setBackups);
}
}, [open, board]);
async function handleRestore(backup: BackupEntry) {
if (!board) return;
// Back up current state before restoring
await saveBoard(board);
const restored = await restoreBackupFile(board.id, backup.filename);
await saveBoard(restored);
// Reload
await useBoardStore.getState().openBoard(board.id);
setConfirmRestore(null);
onOpenChange(false);
}
return (
<>
<Dialog open={open && !confirmRestore} onOpenChange={onOpenChange}>
<DialogContent className="bg-pylon-surface sm:max-w-md">
<DialogHeader>
<DialogTitle className="font-heading text-pylon-text">
Version History
</DialogTitle>
<DialogDescription className="text-pylon-text-secondary">
Browse and restore previous versions of this board.
</DialogDescription>
</DialogHeader>
<OverlayScrollbarsComponent
className="max-h-[300px]"
options={{ scrollbars: { theme: "os-theme-pylon", autoHide: "scroll", autoHideDelay: 600, clickScroll: true }, overflow: { x: "hidden" } }}
defer
>
{backups.length > 0 ? (
<div className="flex flex-col gap-1">
{backups.map((backup) => (
<div
key={backup.filename}
className="flex items-center justify-between rounded px-3 py-2 hover:bg-pylon-column/60"
>
<div className="flex flex-col">
<span className="text-sm text-pylon-text">
{formatDistanceToNow(new Date(backup.timestamp), { addSuffix: true })}
</span>
<span className="font-mono text-xs text-pylon-text-secondary">
{backup.cardCount} cards, {backup.columnCount} columns
</span>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => setConfirmRestore(backup)}
className="text-pylon-accent"
>
Restore
</Button>
</div>
))}
</div>
) : (
<p className="px-3 py-6 text-center text-sm text-pylon-text-secondary">
No backups yet. Backups are created automatically as you work.
</p>
)}
</OverlayScrollbarsComponent>
</DialogContent>
</Dialog>
{/* Restore confirmation */}
<Dialog open={confirmRestore != null} onOpenChange={() => setConfirmRestore(null)}>
<DialogContent className="bg-pylon-surface sm:max-w-sm">
<DialogHeader>
<DialogTitle className="font-heading text-pylon-text">
Restore Version
</DialogTitle>
<DialogDescription className="text-pylon-text-secondary">
This will replace the current board with the selected version. Your current state will be backed up first.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="ghost" onClick={() => setConfirmRestore(null)} className="text-pylon-text-secondary">
Cancel
</Button>
<Button onClick={() => confirmRestore && handleRestore(confirmRestore)}>
Restore
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}
```
**Step 4: Add Version History to TopBar**
In `src/components/layout/TopBar.tsx`, add a "Version History" menu item to the board settings dropdown:
Import and add state:
```typescript
import { VersionHistoryDialog } from "@/components/board/VersionHistoryDialog";
```
In TopBar component:
```typescript
const [showVersionHistory, setShowVersionHistory] = useState(false);
```
Add menu item inside the `DropdownMenuContent` for board settings (after the Attachments submenu):
```typescript
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => setShowVersionHistory(true)}>
Version History
</DropdownMenuItem>
```
Import `DropdownMenuSeparator` and `DropdownMenuItem` (add to existing import).
Render the dialog at the bottom of the component return:
```typescript
{isBoardView && (
<VersionHistoryDialog
open={showVersionHistory}
onOpenChange={setShowVersionHistory}
/>
)}
```
**Step 5: Ensure backups directory is created**
In `src/lib/storage.ts`, update `ensureDataDirs` to create the backups directory:
```typescript
const backupsDir = await join(base, "backups");
if (!(await exists(backupsDir))) {
await mkdir(backupsDir, { recursive: true });
}
```
**Step 6: Verify**
Run `npm run tauri dev`. Make changes to a board. Open board settings > Version History. Backups should be listed. Click Restore on one and confirm.
**Step 7: Commit**
```
git add src/lib/storage.ts src/stores/board-store.ts src/components/board/VersionHistoryDialog.tsx src/components/layout/TopBar.tsx
git commit -m "feat: auto-backup and version history with restore"
```
---
## Summary
| Phase | Tasks | Features |
|-------|-------|----------|
| 0 | 1-3 | Comment type, Card priority+comments, Column collapsed+wipLimit |
| 1 | 4-7 | defaultColumnWidth, due date colors, card aging, open attachments |
| 2 | 8-13 | Priority UI, context menu, WIP limits, collapse columns, checklist reorder |
| 3 | 14-17 | Filter bar, keyboard nav, notifications, comments |
| 4 | 18-19 | Templates, auto-backup + version history |
Each phase is independently shippable. The app remains functional after completing any phase.