# 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 ``` **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 }) => ( ))}
); } ``` **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 ? ( ) : ( {/* 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) => ( ))}
)} {/* Due date filter */} {/* Priority filter */} {/* Spacer + clear + close */}
{isFilterActive(filters) && ( )}
); } ``` **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 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 */}