69 KiB
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):
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):
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:
export type Priority = "none" | "low" | "medium" | "high" | "urgent";
Add two fields to the Card interface (after coverColor):
priority: Priority;
comments: Comment[];
Step 2: Add fields to cardSchema
In src/lib/schemas.ts, add to cardSchema (after coverColor line):
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:
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):
collapsed: boolean;
wipLimit: number | null;
Step 2: Update columnSchema
In src/lib/schemas.ts, add to columnSchema (after color line):
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:
{
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:
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":
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:
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:
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:
{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:
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:
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:
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:
<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:
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:
{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:
{(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:
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:
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:
{/* 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:
duplicateCard: (cardId: string) => string | null;
Add implementation after deleteCard:
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:
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:
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):
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:
setColumnWipLimit: (columnId: string, limit: number | null) => void;
Add implementation after setColumnColor:
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:
const setColumnWipLimit = useBoardStore((s) => s.setColumnWipLimit);
Add a WIP Limit submenu in the dropdown, after the Color submenu:
<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:
<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:
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:
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:
toggleColumnCollapse: (columnId: string) => void;
Add implementation:
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:
const toggleColumnCollapse = useBoardStore((s) => s.toggleColumnCollapse);
Add menu item in the dropdown, after the Rename item:
<DropdownMenuItem onClick={() => toggleColumnCollapse(column.id)}>
Collapse
</DropdownMenuItem>
Step 3: Render collapsed state in KanbanColumn
In src/components/board/KanbanColumn.tsx, add the store action:
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:
animate={{ width: column.collapsed ? 40 : width, opacity: 1 }}
Then wrap the motion.section in a conditional. If collapsed, show:
{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:
reorderChecklistItems: (cardId: string, fromIndex: number, toIndex: number) => void;
Add implementation:
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:
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:
const reorderChecklistItems = useBoardStore((s) => s.reorderChecklistItems);
Add sensors:
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 3 } })
);
Add drag handler:
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:
<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:
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:
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:
import { AnimatePresence } from "framer-motion";
import { FilterBar, type FilterState, EMPTY_FILTER, isFilterActive } from "@/components/board/FilterBar";
Add filter state:
const [showFilterBar, setShowFilterBar] = useState(false);
const [filters, setFilters] = useState<FilterState>(EMPTY_FILTER);
Add filterCards helper inside the component:
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 /:
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:
<AnimatePresence>
{showFilterBar && board && (
<FilterBar
filters={filters}
onChange={setFilters}
onClose={() => { setShowFilterBar(false); setFilters(EMPTY_FILTER); }}
boardLabels={board.labels}
/>
)}
</AnimatePresence>
Pass filteredCardIds to KanbanColumn:
<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:
interface KanbanColumnProps {
column: Column;
filteredCardIds?: string[];
onCardClick?: (cardId: string) => void;
isNew?: boolean;
}
Use it when rendering cards:
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:
<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:
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:
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:
import { useKeyboardNavigation } from "@/hooks/useKeyboardNavigation";
// Inside BoardView:
const { focusedCardId, setFocusedCardId } = useKeyboardNavigation(board, setSelectedCardId);
Pass focusedCardId to KanbanColumn:
<KanbanColumn
...
focusedCardId={focusedCardId}
/>
Clear focus when opening a card (in setSelectedCardId):
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:
interface KanbanColumnProps {
column: Column;
filteredCardIds?: string[];
focusedCardId?: string | null;
onCardClick?: (cardId: string) => void;
isNew?: boolean;
}
In the card render:
<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:
interface CardThumbnailProps {
card: Card;
boardLabels: Label[];
columnId: string;
onCardClick?: (cardId: string) => void;
isFocused?: boolean;
}
Add auto-scroll ref and effect:
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:
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):
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]:
tauri-plugin-notification = "2"
Register the plugin in src-tauri/src/lib.rs (find existing .plugin() calls and add):
.plugin(tauri_plugin_notification::init())
In src-tauri/capabilities/default.json, add to permissions array:
"notification:default"
Install the npm package:
npm install @tauri-apps/plugin-notification
Step 2: Add lastNotificationCheck to settings
In src/types/settings.ts, add to AppSettings:
lastNotificationCheck: string | null;
In src/lib/schemas.ts, add to appSettingsSchema:
lastNotificationCheck: z.string().nullable().default(null),
In src/stores/app-store.ts, add to default settings:
lastNotificationCheck: null,
Step 3: Add notification check to init
In src/stores/app-store.ts, import and add notification logic to init:
import { isPermissionGranted, requestPermission, sendNotification } from "@tauri-apps/plugin-notification";
After set({ settings, boards, initialized: true }) in init, add:
// 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:
addComment: (cardId: string, text: string) => void;
deleteComment: (cardId: string, commentId: string) => void;
Add implementations:
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:
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:
import { CommentsSection } from "@/components/card-detail/CommentsSection";
Add a new full-width row at the bottom of the grid (after attachments):
{/* 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:
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:
async function getTemplatesDir(): Promise<string> {
const base = await getBaseDir();
return join(base, "templates");
}
Update ensureDataDirs to create templates dir:
const templatesDir = await getTemplatesDir();
if (!(await exists(templatesDir))) {
await mkdir(templatesDir, { recursive: true });
}
Add template functions:
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:
import type { BoardTemplate } from "@/types/template";
Step 3: Add createBoardFromTemplate to board-factory
In src/lib/board-factory.ts:
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:
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):
<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:
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:
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:
// 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:
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:
import { VersionHistoryDialog } from "@/components/board/VersionHistoryDialog";
In TopBar component:
const [showVersionHistory, setShowVersionHistory] = useState(false);
Add menu item inside the DropdownMenuContent for board settings (after the Attachments submenu):
<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:
{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:
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.