# 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 ( {format(new Date(card.dueDate), "MMM d")} ); })()} ``` **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 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" > ``` **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 = { 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" && ( )} ``` 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 ( Priority {PRIORITIES.map(({ value, label, color }) => ( 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} ))} ); } ``` **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 */} {/* Row 4: Attachments (full width) */} ``` **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 ( {/* existing content */} ); ``` 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 ( {otherColumns.length > 0 && ( Move to {otherColumns.map((col) => ( moveCard(cardId, columnId, col.id, col.cardIds.length)} > {col.title} ))} )} Set priority {priorities.map(({ value, label }) => ( updateCard(cardId, { priority: value })} > {label} ))} duplicateCard(cardId)}> Duplicate deleteCard(cardId)} > Delete ); } ``` 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 WIP Limit setColumnWipLimit(column.id, v === "none" ? null : parseInt(v))} > None 3 5 7 10 ``` **Step 3: Update ColumnHeader card count display** Replace the card count `` to show WIP status: ```typescript 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}` : ""} ``` **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 toggleColumnCollapse(column.id)}> Collapse ``` **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 ? ( 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 }} > {column.title} {cardCount} ) : ( {/* existing full column content */} )} ``` **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 item.id)} strategy={verticalListSortingStrategy}> {checklist.map((item) => ( ))} ``` **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 ( {/* rest of existing content */} ); } ``` **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(null); const debounceRef = useRef | 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 ( {/* Text search */} 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" /> {/* Label filter chips */} {boardLabels.length > 0 && ( {boardLabels.map((label) => ( 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} ))} )} {/* Due date filter */} 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" > All dates Overdue Due this week Due today No date {/* Priority filter */} 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" > All priorities Urgent High Medium Low No priority {/* Spacer + clear + close */} {isFilterActive(filters) && ( Clear all )} ); } ``` **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(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 {showFilterBar && board && ( { setShowFilterBar(false); setFilters(EMPTY_FILTER); }} boardLabels={board.labels} /> )} ``` Pass `filteredCardIds` to `KanbanColumn`: ```typescript ``` **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 document.dispatchEvent(new CustomEvent("toggle-filter-bar"))} > Filter cards / ``` 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(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 ``` 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 ``` **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(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).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(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 ( Comments {/* Add comment */} 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" /> Add {/* Comment list */} {comments.length > 0 && ( {comments.map((comment) => ( {comment.text} {formatDistanceToNow(new Date(comment.createdAt), { addSuffix: true })} 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" > ))} )} ); } ``` **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) */} ``` **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 { 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 { 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 { 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 { 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 Save as Template ``` 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 { 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 { 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 { 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 { 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 { 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 { 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([]); const [confirmRestore, setConfirmRestore] = useState(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 ( <> Version History Browse and restore previous versions of this board. {backups.length > 0 ? ( {backups.map((backup) => ( {formatDistanceToNow(new Date(backup.timestamp), { addSuffix: true })} {backup.cardCount} cards, {backup.columnCount} columns setConfirmRestore(backup)} className="text-pylon-accent" > Restore ))} ) : ( No backups yet. Backups are created automatically as you work. )} {/* Restore confirmation */} setConfirmRestore(null)}> Restore Version This will replace the current board with the selected version. Your current state will be backed up first. setConfirmRestore(null)} className="text-pylon-text-secondary"> Cancel confirmRestore && handleRestore(confirmRestore)}> Restore > ); } ``` **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 setShowVersionHistory(true)}> Version History ``` Import `DropdownMenuSeparator` and `DropdownMenuItem` (add to existing import). Render the dialog at the bottom of the component return: ```typescript {isBoardView && ( )} ``` **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.
{comment.text}
No backups yet. Backups are created automatically as you work.