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

69 KiB

15 Improvements Implementation Plan

For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

Goal: Implement 15 improvements to OpenPylon kanban app across 5 phases, from data model changes through infrastructure features.

Architecture: All features follow existing patterns: Zustand store with mutate(get, set, (b) => ...) for board mutations, Zod schemas with .default() for backwards-compatible data evolution, Radix UI for menus/dialogs, Framer Motion for animations, OKLCH colors, Tailwind CSS 4.

Tech Stack: Tauri v2, React 19, TypeScript, Zustand 5 (with zundo temporal), Tailwind CSS 4, Radix UI, dnd-kit, Framer Motion, date-fns, ulid

Note: This project has no test framework. Verification is done by running npm run tauri dev and manually testing each feature.


Phase 0: Data Model Foundation

All type/schema changes that later features depend on.


Task 1: Add Comment type and schema

Files:

  • Modify: src/types/board.ts
  • Modify: src/lib/schemas.ts

Step 1: Add Comment interface to types

In src/types/board.ts, add after the ChecklistItem interface (line 46):

export interface Comment {
  id: string;
  text: string;
  createdAt: string;
}

Step 2: Add commentSchema to schemas

In src/lib/schemas.ts, add after checklistItemSchema (line 7):

export const commentSchema = z.object({
  id: z.string(),
  text: z.string(),
  createdAt: z.string(),
});

Step 3: Verify

Run: npx tsc --noEmit Expected: No errors

Step 4: Commit

git add src/types/board.ts src/lib/schemas.ts
git commit -m "feat: add Comment type and schema"

Task 2: Add priority and comments fields to Card

Files:

  • Modify: src/types/board.ts
  • Modify: src/lib/schemas.ts

Step 1: Add Priority type and update Card interface

In src/types/board.ts, add before the Card interface:

export type Priority = "none" | "low" | "medium" | "high" | "urgent";

Add two fields to the Card interface (after coverColor):

priority: Priority;
comments: Comment[];

Step 2: Add fields to cardSchema

In src/lib/schemas.ts, add to cardSchema (after coverColor line):

priority: z.enum(["none", "low", "medium", "high", "urgent"]).default("none"),
comments: z.array(commentSchema).default([]),

Step 3: Update addCard in board-store.ts

In src/stores/board-store.ts, update the addCard action's card creation (around line 198-209) to include new fields:

const card: Card = {
  id: cardId,
  title,
  description: "",
  labels: [],
  checklist: [],
  dueDate: null,
  attachments: [],
  coverColor: null,
  priority: "none",
  comments: [],
  createdAt: now(),
  updatedAt: now(),
};

Also update the import from @/types/board to include Priority.

Step 4: Verify

Run: npx tsc --noEmit Expected: No errors

Step 5: Commit

git add src/types/board.ts src/lib/schemas.ts src/stores/board-store.ts
git commit -m "feat: add priority and comments fields to Card"

Task 3: Add collapsed and wipLimit fields to Column

Files:

  • Modify: src/types/board.ts
  • Modify: src/lib/schemas.ts

Step 1: Update Column interface

In src/types/board.ts, add two fields to the Column interface (after color):

collapsed: boolean;
wipLimit: number | null;

Step 2: Update columnSchema

In src/lib/schemas.ts, add to columnSchema (after color line):

collapsed: z.boolean().default(false),
wipLimit: z.number().nullable().default(null),

Step 3: Update addColumn in board-store.ts

In src/stores/board-store.ts, update the column creation in addColumn (around line 130-136) to include new fields:

{
  id: ulid(),
  title,
  cardIds: [],
  width: "standard" as ColumnWidth,
  color: null,
  collapsed: false,
  wipLimit: null,
}

Step 4: Update board-factory.ts

In src/lib/board-factory.ts, update the col helper (around line 24-30) to include new fields:

const col = (t: string, w: ColumnWidth = "standard") => ({
  id: ulid(),
  title: t,
  cardIds: [] as string[],
  width: w,
  color: null as string | null,
  collapsed: false,
  wipLimit: null as number | null,
});

Step 5: Verify

Run: npx tsc --noEmit Expected: No errors

Step 6: Commit

git add src/types/board.ts src/lib/schemas.ts src/stores/board-store.ts src/lib/board-factory.ts
git commit -m "feat: add collapsed and wipLimit fields to Column"

Phase 1: Quick Wins

Minimal changes, high value.


Task 4: #8 — Consume defaultColumnWidth setting

Files:

  • Modify: src/stores/board-store.ts

Step 1: Read setting in addColumn

In src/stores/board-store.ts, update addColumn to read the setting. Replace the hardcoded "standard":

addColumn: (title: string) => {
  const defaultWidth = useAppStore.getState().settings.defaultColumnWidth;
  mutate(get, set, (b) => ({
    ...b,
    updatedAt: now(),
    columns: [
      ...b.columns,
      {
        id: ulid(),
        title,
        cardIds: [],
        width: defaultWidth,
        color: null,
        collapsed: false,
        wipLimit: null,
      },
    ],
  }));
},

Add import at top of file:

import { useAppStore } from "@/stores/app-store";

Step 2: Verify

Run npm run tauri dev. Change default column width in Settings, add a new column. It should use the selected width.

Step 3: Commit

git add src/stores/board-store.ts
git commit -m "feat: addColumn reads defaultColumnWidth from settings"

Task 5: #4 — Due date visual indicators

Files:

  • Modify: src/components/board/CardThumbnail.tsx

Step 1: Add getDueDateStatus helper and update rendering

In src/components/board/CardThumbnail.tsx, replace the existing due date logic (lines 36-38) and the due date rendering in the footer (lines 109-119).

Add this helper function before the CardThumbnail component:

function getDueDateStatus(dueDate: string | null): { color: string; bgColor: string; label: string } | null {
  if (!dueDate) return null;
  const date = new Date(dueDate);
  const now = new Date();
  const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
  const dueDay = new Date(date.getFullYear(), date.getMonth(), date.getDate());
  const diffDays = Math.ceil((dueDay.getTime() - today.getTime()) / (1000 * 60 * 60 * 24));

  if (diffDays < 0) {
    return { color: "text-pylon-danger", bgColor: "bg-pylon-danger/10", label: "Overdue" };
  }
  if (diffDays <= 2) {
    return { color: "text-[oklch(65%_0.15_70)]", bgColor: "bg-[oklch(65%_0.15_70/10%)]", label: "Due soon" };
  }
  return { color: "text-[oklch(55%_0.12_145)]", bgColor: "bg-[oklch(55%_0.12_145/10%)]", label: "Upcoming" };
}

Remove the old overdue variable and isPast/isToday imports (keep format from date-fns). Replace the due date span in the footer row:

{card.dueDate && (() => {
  const status = getDueDateStatus(card.dueDate);
  if (!status) return null;
  return (
    <span
      className={`font-mono text-xs rounded px-1 py-0.5 ${status.color} ${status.bgColor}`}
      title={status.label}
    >
      {format(new Date(card.dueDate), "MMM d")}
    </span>
  );
})()}

Step 2: Clean up imports

Remove isPast, isToday from the date-fns import since they're no longer needed.

Step 3: Verify

Run npm run tauri dev. Create cards with due dates: past dates should be red, dates within 2 days should be amber, dates further out should be green.

Step 4: Commit

git add src/components/board/CardThumbnail.tsx
git commit -m "feat: color-coded due date indicators (red/amber/green)"

Task 6: #9 — Card aging visualization

Files:

  • Modify: src/components/board/CardThumbnail.tsx

Step 1: Add aging opacity helper

Add this helper near getDueDateStatus:

function getAgingOpacity(updatedAt: string): number {
  const days = (Date.now() - new Date(updatedAt).getTime()) / (1000 * 60 * 60 * 24);
  if (days <= 7) return 1.0;
  if (days <= 14) return 0.85;
  if (days <= 30) return 0.7;
  return 0.55;
}

Step 2: Apply opacity to card

In the motion.button element, add opacity to the style prop:

style={{
  transform: CSS.Transform.toString(transform),
  transition,
  padding: `calc(0.75rem * var(--density-factor))`,
  opacity: getAgingOpacity(card.updatedAt),
}}

Step 3: Verify

Run npm run tauri dev. Cards updated recently should be fully opaque. Old cards should appear faded.

Step 4: Commit

git add src/components/board/CardThumbnail.tsx
git commit -m "feat: card aging visualization - stale cards fade"

Task 7: #12 — Open attachments

Files:

  • Modify: src/components/card-detail/AttachmentSection.tsx

Step 1: Add Open button to each attachment

Import the opener and add an icon:

import { openPath } from "@tauri-apps/plugin-opener";
import { FileIcon, X, Plus, ExternalLink } from "lucide-react";

In the attachment row (inside the .map), add an open button before the remove button:

<button
  onClick={() => openPath(att.path)}
  className="shrink-0 rounded p-0.5 text-pylon-text-secondary opacity-0 transition-opacity hover:bg-pylon-accent/10 hover:text-pylon-accent group-hover/att:opacity-100"
  aria-label="Open attachment"
>
  <ExternalLink className="size-3" />
</button>

Step 2: Verify

Run npm run tauri dev. Add an attachment to a card. The "Open" icon should appear on hover and open the file with the system default application.

Step 3: Commit

git add src/components/card-detail/AttachmentSection.tsx
git commit -m "feat: open attachments with system default app"

Phase 2: Card Interactions & UI Enhancements


Task 8: #2 — Card priority levels (thumbnail indicator)

Files:

  • Modify: src/components/board/CardThumbnail.tsx

Step 1: Add priority color map and dot

Add constant near top of file:

const PRIORITY_COLORS: Record<string, string> = {
  low: "oklch(60% 0.15 240)",      // blue
  medium: "oklch(70% 0.15 85)",    // yellow
  high: "oklch(60% 0.15 55)",      // orange
  urgent: "oklch(55% 0.15 25)",    // red
};

In the footer row (the div with mt-2 flex items-center gap-3), add priority dot at the start:

{card.priority !== "none" && (
  <span
    className={`size-2.5 shrink-0 rounded-full ${card.priority === "urgent" ? "animate-pulse" : ""}`}
    style={{ backgroundColor: PRIORITY_COLORS[card.priority] }}
    title={`Priority: ${card.priority}`}
  />
)}

Also update the footer row's show condition to include priority:

{(hasDueDate || card.checklist.length > 0 || card.attachments.length > 0 || card.description || card.priority !== "none") && (

Step 2: Verify

Run npm run tauri dev. Set priorities on cards (we'll add the picker in the next task). For now verify with no TypeScript errors: npx tsc --noEmit.

Step 3: Commit

git add src/components/board/CardThumbnail.tsx
git commit -m "feat: priority dot indicator on card thumbnails"

Task 9: #2 — Card priority levels (detail modal picker)

Files:

  • Create: src/components/card-detail/PriorityPicker.tsx
  • Modify: src/components/card-detail/CardDetailModal.tsx

Step 1: Create PriorityPicker component

Create src/components/card-detail/PriorityPicker.tsx:

import { useBoardStore } from "@/stores/board-store";
import type { Priority } from "@/types/board";

const PRIORITIES: { value: Priority; label: string; color: string }[] = [
  { value: "none", label: "None", color: "oklch(50% 0 0 / 30%)" },
  { value: "low", label: "Low", color: "oklch(60% 0.15 240)" },
  { value: "medium", label: "Medium", color: "oklch(70% 0.15 85)" },
  { value: "high", label: "High", color: "oklch(60% 0.15 55)" },
  { value: "urgent", label: "Urgent", color: "oklch(55% 0.15 25)" },
];

interface PriorityPickerProps {
  cardId: string;
  priority: Priority;
}

export function PriorityPicker({ cardId, priority }: PriorityPickerProps) {
  const updateCard = useBoardStore((s) => s.updateCard);

  return (
    <div className="flex flex-col gap-2">
      <h4 className="font-mono text-xs uppercase tracking-widest text-pylon-text-secondary">
        Priority
      </h4>
      <div className="flex flex-wrap gap-1.5">
        {PRIORITIES.map(({ value, label, color }) => (
          <button
            key={value}
            onClick={() => updateCard(cardId, { priority: value })}
            className={`rounded-full px-3 py-1 text-xs font-medium transition-all ${
              priority === value
                ? "ring-2 ring-offset-1 ring-offset-pylon-column/50 text-white"
                : "text-pylon-text-secondary hover:text-pylon-text"
            }`}
            style={{
              backgroundColor: priority === value ? color : undefined,
              borderColor: color,
              border: priority !== value ? `1px solid ${color}` : undefined,
              ringColor: color,
            }}
          >
            {label}
          </button>
        ))}
      </div>
    </div>
  );
}

Step 2: Add PriorityPicker to CardDetailModal

In src/components/card-detail/CardDetailModal.tsx, import the new component:

import { PriorityPicker } from "@/components/card-detail/PriorityPicker";

Add a new grid cell for Priority. Insert it in the dashboard grid — add it as a new row before the Cover color section. Replace the Row 3 comment block (Cover + Attachments) with:

{/* Row 3: Priority + Cover */}
<motion.div
  className="rounded-lg bg-pylon-column/50 p-4"
  variants={fadeSlideUp}
  transition={springs.bouncy}
>
  <PriorityPicker cardId={cardId} priority={card.priority} />
</motion.div>

<motion.div
  className="rounded-lg bg-pylon-column/50 p-4"
  variants={fadeSlideUp}
  transition={springs.bouncy}
>
  <CoverColorPicker
    cardId={cardId}
    coverColor={card.coverColor}
  />
</motion.div>

{/* Row 4: Attachments (full width) */}
<motion.div
  className="col-span-2 rounded-lg bg-pylon-column/50 p-4"
  variants={fadeSlideUp}
  transition={springs.bouncy}
>
  <AttachmentSection
    cardId={cardId}
    attachments={card.attachments}
  />
</motion.div>

Step 3: Verify

Run npm run tauri dev. Open a card detail. Priority picker should show 5 chips. Click one — the card thumbnail should show the corresponding colored dot.

Step 4: Commit

git add src/components/card-detail/PriorityPicker.tsx src/components/card-detail/CardDetailModal.tsx
git commit -m "feat: priority picker in card detail modal"

Task 10: #5 — Card context menu

Files:

  • Modify: src/components/board/CardThumbnail.tsx
  • Modify: src/stores/board-store.ts

Step 1: Add duplicateCard store action

In src/stores/board-store.ts, add to BoardActions interface:

duplicateCard: (cardId: string) => string | null;

Add implementation after deleteCard:

duplicateCard: (cardId) => {
  const { board } = get();
  if (!board) return null;
  const original = board.cards[cardId];
  if (!original) return null;

  const column = board.columns.find((c) => c.cardIds.includes(cardId));
  if (!column) return null;

  const newId = ulid();
  const ts = now();
  const clone: Card = {
    ...original,
    id: newId,
    title: `${original.title} (copy)`,
    comments: [],
    createdAt: ts,
    updatedAt: ts,
  };

  const insertIndex = column.cardIds.indexOf(cardId) + 1;

  mutate(get, set, (b) => ({
    ...b,
    updatedAt: ts,
    cards: { ...b.cards, [newId]: clone },
    columns: b.columns.map((c) =>
      c.id === column.id
        ? {
            ...c,
            cardIds: [
              ...c.cardIds.slice(0, insertIndex),
              newId,
              ...c.cardIds.slice(insertIndex),
            ],
          }
        : c
    ),
  }));

  return newId;
},

Step 2: Wrap CardThumbnail in ContextMenu

In src/components/board/CardThumbnail.tsx, add imports:

import {
  ContextMenu,
  ContextMenuContent,
  ContextMenuItem,
  ContextMenuSeparator,
  ContextMenuSub,
  ContextMenuSubContent,
  ContextMenuSubTrigger,
} from "@/components/ui/context-menu";
import { useBoardStore } from "@/stores/board-store";
import type { Priority } from "@/types/board";

Wrap the motion.button in a ContextMenu. The return for the non-dragging case becomes:

return (
  <ContextMenu>
    <ContextMenuTrigger asChild>
      <motion.button ...> {/* existing content */} </motion.button>
    </ContextMenuTrigger>
    <CardContextMenuContent cardId={card.id} columnId={columnId} />
  </ContextMenu>
);

Add a CardContextMenuContent component (can be inline in the same file):

function CardContextMenuContent({ cardId, columnId }: { cardId: string; columnId: string }) {
  const board = useBoardStore((s) => s.board);
  const moveCard = useBoardStore((s) => s.moveCard);
  const updateCard = useBoardStore((s) => s.updateCard);
  const duplicateCard = useBoardStore((s) => s.duplicateCard);
  const deleteCard = useBoardStore((s) => s.deleteCard);

  if (!board) return null;

  const otherColumns = board.columns.filter((c) => c.id !== columnId);
  const priorities: { value: Priority; label: string }[] = [
    { value: "none", label: "None" },
    { value: "low", label: "Low" },
    { value: "medium", label: "Medium" },
    { value: "high", label: "High" },
    { value: "urgent", label: "Urgent" },
  ];

  return (
    <ContextMenuContent>
      {otherColumns.length > 0 && (
        <ContextMenuSub>
          <ContextMenuSubTrigger>Move to</ContextMenuSubTrigger>
          <ContextMenuSubContent>
            {otherColumns.map((col) => (
              <ContextMenuItem
                key={col.id}
                onClick={() => moveCard(cardId, columnId, col.id, col.cardIds.length)}
              >
                {col.title}
              </ContextMenuItem>
            ))}
          </ContextMenuSubContent>
        </ContextMenuSub>
      )}
      <ContextMenuSub>
        <ContextMenuSubTrigger>Set priority</ContextMenuSubTrigger>
        <ContextMenuSubContent>
          {priorities.map(({ value, label }) => (
            <ContextMenuItem
              key={value}
              onClick={() => updateCard(cardId, { priority: value })}
            >
              {label}
            </ContextMenuItem>
          ))}
        </ContextMenuSubContent>
      </ContextMenuSub>
      <ContextMenuItem onClick={() => duplicateCard(cardId)}>
        Duplicate
      </ContextMenuItem>
      <ContextMenuSeparator />
      <ContextMenuItem
        variant="destructive"
        onClick={() => deleteCard(cardId)}
      >
        Delete
      </ContextMenuItem>
    </ContextMenuContent>
  );
}

Also add import { ContextMenuTrigger } from "@/components/ui/context-menu"; to the imports if not already there.

Step 3: Verify

Run npm run tauri dev. Right-click a card. Context menu should show: Move to (submenu), Set priority (submenu), Duplicate, Delete.

Step 4: Commit

git add src/components/board/CardThumbnail.tsx src/stores/board-store.ts
git commit -m "feat: card context menu with move, priority, duplicate, delete"

Task 11: #10 — WIP limits

Files:

  • Modify: src/stores/board-store.ts
  • Modify: src/components/board/ColumnHeader.tsx
  • Modify: src/components/board/KanbanColumn.tsx

Step 1: Add setColumnWipLimit store action

In src/stores/board-store.ts, add to BoardActions:

setColumnWipLimit: (columnId: string, limit: number | null) => void;

Add implementation after setColumnColor:

setColumnWipLimit: (columnId, limit) => {
  mutate(get, set, (b) => ({
    ...b,
    updatedAt: now(),
    columns: b.columns.map((c) =>
      c.id === columnId ? { ...c, wipLimit: limit } : c
    ),
  }));
},

Step 2: Update ColumnHeader with WIP limit menu item

In src/components/board/ColumnHeader.tsx, add the store action:

const setColumnWipLimit = useBoardStore((s) => s.setColumnWipLimit);

Add a WIP Limit submenu in the dropdown, after the Color submenu:

<DropdownMenuSub>
  <DropdownMenuSubTrigger>WIP Limit</DropdownMenuSubTrigger>
  <DropdownMenuSubContent>
    <DropdownMenuRadioGroup
      value={column.wipLimit?.toString() ?? "none"}
      onValueChange={(v) => setColumnWipLimit(column.id, v === "none" ? null : parseInt(v))}
    >
      <DropdownMenuRadioItem value="none">None</DropdownMenuRadioItem>
      <DropdownMenuRadioItem value="3">3</DropdownMenuRadioItem>
      <DropdownMenuRadioItem value="5">5</DropdownMenuRadioItem>
      <DropdownMenuRadioItem value="7">7</DropdownMenuRadioItem>
      <DropdownMenuRadioItem value="10">10</DropdownMenuRadioItem>
    </DropdownMenuRadioGroup>
  </DropdownMenuSubContent>
</DropdownMenuSub>

Step 3: Update ColumnHeader card count display

Replace the card count <span> to show WIP status:

<span className={`shrink-0 font-mono text-xs ${
  column.wipLimit != null && cardCount > column.wipLimit
    ? "text-pylon-danger font-bold"
    : column.wipLimit != null && cardCount === column.wipLimit
      ? "text-[oklch(65%_0.15_70)]"
      : "text-pylon-text-secondary"
}`}>
  {cardCount}{column.wipLimit != null ? `/${column.wipLimit}` : ""}
</span>

Step 4: Update KanbanColumn with WIP tint

In src/components/board/KanbanColumn.tsx, compute a background tint based on WIP limit status. Add after const cardCount:

const wipTint = column.wipLimit != null
  ? cardCount > column.wipLimit
    ? "oklch(70% 0.08 25 / 15%)"   // red tint - over limit
    : cardCount === column.wipLimit
      ? "oklch(75% 0.08 70 / 15%)" // amber tint - at limit
      : undefined
  : undefined;

Apply it to the motion.section as an additional background style. Update the style prop on the motion.section:

style={{ borderTop, backgroundColor: wipTint }}

Step 5: Verify

Run npm run tauri dev. Set a WIP limit on a column. Add cards to exceed it. The header count should change color and the column should get a tinted background.

Step 6: Commit

git add src/stores/board-store.ts src/components/board/ColumnHeader.tsx src/components/board/KanbanColumn.tsx
git commit -m "feat: WIP limits with visual indicators"

Task 12: #3 — Column collapse/expand

Files:

  • Modify: src/stores/board-store.ts
  • Modify: src/components/board/ColumnHeader.tsx
  • Modify: src/components/board/KanbanColumn.tsx

Step 1: Add toggleColumnCollapse store action

In src/stores/board-store.ts, add to BoardActions:

toggleColumnCollapse: (columnId: string) => void;

Add implementation:

toggleColumnCollapse: (columnId) => {
  mutate(get, set, (b) => ({
    ...b,
    updatedAt: now(),
    columns: b.columns.map((c) =>
      c.id === columnId ? { ...c, collapsed: !c.collapsed } : c
    ),
  }));
},

Step 2: Add Collapse menu item to ColumnHeader

In src/components/board/ColumnHeader.tsx, add the store action:

const toggleColumnCollapse = useBoardStore((s) => s.toggleColumnCollapse);

Add menu item in the dropdown, after the Rename item:

<DropdownMenuItem onClick={() => toggleColumnCollapse(column.id)}>
  Collapse
</DropdownMenuItem>

Step 3: Render collapsed state in KanbanColumn

In src/components/board/KanbanColumn.tsx, add the store action:

const toggleColumnCollapse = useBoardStore((s) => s.toggleColumnCollapse);

Add import for ChevronRight from lucide-react.

Inside the outer motion.div, before the motion.section, add a collapsed view. The logic: if column.collapsed, render the narrow strip instead of the full column. Update the animate on the outer motion.div:

animate={{ width: column.collapsed ? 40 : width, opacity: 1 }}

Then wrap the motion.section in a conditional. If collapsed, show:

{column.collapsed ? (
  <button
    onClick={() => toggleColumnCollapse(column.id)}
    className="flex h-full w-full flex-col items-center gap-2 rounded-lg bg-pylon-column py-3 hover:bg-pylon-column/80 transition-colors"
    style={{ borderTop }}
  >
    <ChevronRight className="size-3.5 text-pylon-text-secondary" />
    <span
      className="font-mono text-xs font-semibold uppercase tracking-widest text-pylon-text-secondary"
      style={{ writingMode: "vertical-rl", transform: "rotate(180deg)" }}
    >
      {column.title}
    </span>
    <span className="mt-auto font-mono text-xs text-pylon-text-secondary">
      {cardCount}
    </span>
  </button>
) : (
  <motion.section ...> {/* existing full column content */} </motion.section>
)}

Step 4: Verify

Run npm run tauri dev. Use the column header dropdown to collapse a column. It should shrink to a 40px strip with vertical text. Click the strip to expand.

Step 5: Commit

git add src/stores/board-store.ts src/components/board/ColumnHeader.tsx src/components/board/KanbanColumn.tsx
git commit -m "feat: column collapse/expand with animated transition"

Task 13: #11 — Checklist item reordering

Files:

  • Modify: src/stores/board-store.ts
  • Modify: src/components/card-detail/ChecklistSection.tsx

Step 1: Add reorderChecklistItems store action

In src/stores/board-store.ts, add to BoardActions:

reorderChecklistItems: (cardId: string, fromIndex: number, toIndex: number) => void;

Add implementation:

reorderChecklistItems: (cardId, fromIndex, toIndex) => {
  mutate(get, set, (b) => {
    const card = b.cards[cardId];
    if (!card) return b;
    const items = [...card.checklist];
    const [moved] = items.splice(fromIndex, 1);
    items.splice(toIndex, 0, moved);
    return {
      ...b,
      updatedAt: now(),
      cards: {
        ...b.cards,
        [cardId]: { ...card, checklist: items, updatedAt: now() },
      },
    };
  });
},

Step 2: Add dnd-kit to ChecklistSection

In src/components/card-detail/ChecklistSection.tsx, add imports:

import {
  DndContext,
  closestCenter,
  PointerSensor,
  useSensor,
  useSensors,
  type DragEndEvent,
} from "@dnd-kit/core";
import {
  SortableContext,
  verticalListSortingStrategy,
  useSortable,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { GripVertical, X } from "lucide-react";

Add store action:

const reorderChecklistItems = useBoardStore((s) => s.reorderChecklistItems);

Add sensors:

const sensors = useSensors(
  useSensor(PointerSensor, { activationConstraint: { distance: 3 } })
);

Add drag handler:

function handleDragEnd(event: DragEndEvent) {
  const { active, over } = event;
  if (!over || active.id === over.id) return;
  const oldIndex = checklist.findIndex((item) => item.id === active.id);
  const newIndex = checklist.findIndex((item) => item.id === over.id);
  if (oldIndex !== -1 && newIndex !== -1) {
    reorderChecklistItems(cardId, oldIndex, newIndex);
  }
}

Wrap the checklist items div in DndContext + SortableContext:

<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
  <SortableContext items={checklist.map((item) => item.id)} strategy={verticalListSortingStrategy}>
    <div className="flex flex-col gap-1">
      {checklist.map((item) => (
        <ChecklistRow ... />
      ))}
    </div>
  </SortableContext>
</DndContext>

Step 3: Make ChecklistRow sortable

Update ChecklistRow to use useSortable:

function ChecklistRow({ item, onToggle, onUpdate, onDelete }: ChecklistRowProps) {
  const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
    id: item.id,
  });
  // ...existing state...

  return (
    <div
      ref={setNodeRef}
      style={{
        transform: CSS.Transform.toString(transform),
        transition,
        opacity: isDragging ? 0.5 : undefined,
      }}
      className="group/item flex items-center gap-2 rounded px-1 py-0.5 hover:bg-pylon-column/60"
      {...attributes}
    >
      <span
        className="shrink-0 cursor-grab text-pylon-text-secondary opacity-0 group-hover/item:opacity-100"
        {...listeners}
      >
        <GripVertical className="size-3" />
      </span>
      {/* rest of existing content */}
    </div>
  );
}

Step 4: Verify

Run npm run tauri dev. Open a card with checklist items. Drag items by the grip handle to reorder them.

Step 5: Commit

git add src/stores/board-store.ts src/components/card-detail/ChecklistSection.tsx
git commit -m "feat: drag-and-drop checklist item reordering"

Phase 3: Navigation & Power User Features


Files:

  • Create: src/components/board/FilterBar.tsx
  • Modify: src/components/board/BoardView.tsx
  • Modify: src/components/board/KanbanColumn.tsx
  • Modify: src/components/layout/TopBar.tsx

Step 1: Create FilterBar component

Create src/components/board/FilterBar.tsx:

import { useState, useCallback, useEffect, useRef } from "react";
import { motion } from "framer-motion";
import { springs } from "@/lib/motion";
import { X, Search } from "lucide-react";
import { Button } from "@/components/ui/button";
import type { Label, Priority } from "@/types/board";

export interface FilterState {
  text: string;
  labels: string[];
  dueDate: "all" | "overdue" | "week" | "today" | "none";
  priority: "all" | Priority;
}

export const EMPTY_FILTER: FilterState = {
  text: "",
  labels: [],
  dueDate: "all",
  priority: "all",
};

export function isFilterActive(f: FilterState): boolean {
  return f.text !== "" || f.labels.length > 0 || f.dueDate !== "all" || f.priority !== "all";
}

interface FilterBarProps {
  filters: FilterState;
  onChange: (filters: FilterState) => void;
  onClose: () => void;
  boardLabels: Label[];
}

export function FilterBar({ filters, onChange, onClose, boardLabels }: FilterBarProps) {
  const inputRef = useRef<HTMLInputElement>(null);
  const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
  const [textDraft, setTextDraft] = useState(filters.text);

  useEffect(() => {
    inputRef.current?.focus();
  }, []);

  const handleTextChange = useCallback(
    (value: string) => {
      setTextDraft(value);
      if (debounceRef.current) clearTimeout(debounceRef.current);
      debounceRef.current = setTimeout(() => {
        onChange({ ...filters, text: value });
      }, 200);
    },
    [filters, onChange]
  );

  function toggleLabel(labelId: string) {
    const labels = filters.labels.includes(labelId)
      ? filters.labels.filter((l) => l !== labelId)
      : [...filters.labels, labelId];
    onChange({ ...filters, labels });
  }

  function clearAll() {
    setTextDraft("");
    onChange(EMPTY_FILTER);
  }

  return (
    <motion.div
      initial={{ height: 0, opacity: 0 }}
      animate={{ height: "auto", opacity: 1 }}
      exit={{ height: 0, opacity: 0 }}
      transition={springs.snappy}
      className="overflow-hidden border-b border-border bg-pylon-surface"
    >
      <div className="flex items-center gap-3 px-4 py-2">
        {/* Text search */}
        <div className="flex items-center gap-1.5 rounded-md bg-pylon-column px-2 py-1">
          <Search className="size-3.5 text-pylon-text-secondary" />
          <input
            ref={inputRef}
            value={textDraft}
            onChange={(e) => handleTextChange(e.target.value)}
            placeholder="Search cards..."
            className="h-5 w-40 bg-transparent text-sm text-pylon-text outline-none placeholder:text-pylon-text-secondary/60"
          />
        </div>

        {/* Label filter chips */}
        {boardLabels.length > 0 && (
          <div className="flex items-center gap-1">
            {boardLabels.map((label) => (
              <button
                key={label.id}
                onClick={() => toggleLabel(label.id)}
                className={`rounded-full px-2 py-0.5 text-xs transition-all ${
                  filters.labels.includes(label.id)
                    ? "text-white"
                    : "opacity-40 hover:opacity-70"
                }`}
                style={{ backgroundColor: label.color }}
              >
                {label.name}
              </button>
            ))}
          </div>
        )}

        {/* Due date filter */}
        <select
          value={filters.dueDate}
          onChange={(e) => onChange({ ...filters, dueDate: e.target.value as FilterState["dueDate"] })}
          className="h-6 rounded-md bg-pylon-column px-2 text-xs text-pylon-text outline-none"
        >
          <option value="all">All dates</option>
          <option value="overdue">Overdue</option>
          <option value="week">Due this week</option>
          <option value="today">Due today</option>
          <option value="none">No date</option>
        </select>

        {/* Priority filter */}
        <select
          value={filters.priority}
          onChange={(e) => onChange({ ...filters, priority: e.target.value as FilterState["priority"] })}
          className="h-6 rounded-md bg-pylon-column px-2 text-xs text-pylon-text outline-none"
        >
          <option value="all">All priorities</option>
          <option value="urgent">Urgent</option>
          <option value="high">High</option>
          <option value="medium">Medium</option>
          <option value="low">Low</option>
          <option value="none">No priority</option>
        </select>

        {/* Spacer + clear + close */}
        <div className="flex-1" />
        {isFilterActive(filters) && (
          <Button variant="ghost" size="sm" onClick={clearAll} className="text-xs text-pylon-text-secondary">
            Clear all
          </Button>
        )}
        <Button variant="ghost" size="icon-xs" onClick={onClose} className="text-pylon-text-secondary hover:text-pylon-text">
          <X className="size-3.5" />
        </Button>
      </div>
    </motion.div>
  );
}

Step 2: Add filter logic to BoardView

In src/components/board/BoardView.tsx, import and wire the filter bar:

import { AnimatePresence } from "framer-motion";
import { FilterBar, type FilterState, EMPTY_FILTER, isFilterActive } from "@/components/board/FilterBar";

Add filter state:

const [showFilterBar, setShowFilterBar] = useState(false);
const [filters, setFilters] = useState<FilterState>(EMPTY_FILTER);

Add filterCards helper inside the component:

function filterCards(cardIds: string[]): string[] {
  if (!isFilterActive(filters) || !board) return cardIds;
  return cardIds.filter((id) => {
    const card = board.cards[id];
    if (!card) return false;
    if (filters.text && !card.title.toLowerCase().includes(filters.text.toLowerCase())) return false;
    if (filters.labels.length > 0 && !filters.labels.some((l) => card.labels.includes(l))) return false;
    if (filters.priority !== "all" && card.priority !== filters.priority) return false;
    if (filters.dueDate !== "all") {
      const now = new Date();
      const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
      if (filters.dueDate === "none" && card.dueDate != null) return false;
      if (filters.dueDate === "overdue" && (!card.dueDate || new Date(card.dueDate) >= today)) return false;
      if (filters.dueDate === "today") {
        if (!card.dueDate) return false;
        const d = new Date(card.dueDate);
        if (d.toDateString() !== today.toDateString()) return false;
      }
      if (filters.dueDate === "week") {
        if (!card.dueDate) return false;
        const d = new Date(card.dueDate);
        const weekEnd = new Date(today);
        weekEnd.setDate(weekEnd.getDate() + 7);
        if (d < today || d > weekEnd) return false;
      }
    }
    return true;
  });
}

Add keyboard shortcut for /:

useEffect(() => {
  function handleKey(e: KeyboardEvent) {
    if (e.key === "/" && !e.ctrlKey && !e.metaKey) {
      const tag = (e.target as HTMLElement).tagName;
      if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return;
      e.preventDefault();
      setShowFilterBar(true);
    }
  }
  document.addEventListener("keydown", handleKey);
  return () => document.removeEventListener("keydown", handleKey);
}, []);

Render FilterBar above the DndContext, inside the main fragment:

<AnimatePresence>
  {showFilterBar && board && (
    <FilterBar
      filters={filters}
      onChange={setFilters}
      onClose={() => { setShowFilterBar(false); setFilters(EMPTY_FILTER); }}
      boardLabels={board.labels}
    />
  )}
</AnimatePresence>

Pass filteredCardIds to KanbanColumn:

<KanbanColumn
  key={column.id}
  column={column}
  filteredCardIds={isFilterActive(filters) ? filterCards(column.cardIds) : undefined}
  onCardClick={setSelectedCardId}
  isNew={!initialColumnIds.current?.has(column.id)}
/>

Step 3: Update KanbanColumn to accept filteredCardIds

In src/components/board/KanbanColumn.tsx, add filteredCardIds prop:

interface KanbanColumnProps {
  column: Column;
  filteredCardIds?: string[];
  onCardClick?: (cardId: string) => void;
  isNew?: boolean;
}

Use it when rendering cards:

const displayCardIds = filteredCardIds ?? column.cardIds;
const isFiltering = filteredCardIds != null;

Update the card count display and the card rendering to use displayCardIds. If filtering, show "3 of 7" style count in the column header area.

Step 4: Add filter button to TopBar

In src/components/layout/TopBar.tsx, add a Filter button next to the board settings button. Import Filter from lucide-react.

Add button that dispatches a custom event:

<Tooltip>
  <TooltipTrigger asChild>
    <Button
      variant="ghost"
      size="icon-sm"
      className="text-pylon-text-secondary hover:text-pylon-text"
      onClick={() => document.dispatchEvent(new CustomEvent("toggle-filter-bar"))}
    >
      <Filter className="size-4" />
    </Button>
  </TooltipTrigger>
  <TooltipContent>
    Filter cards <kbd className="ml-1 font-mono text-[10px] opacity-60">/</kbd>
  </TooltipContent>
</Tooltip>

In BoardView, listen for this event:

useEffect(() => {
  function handleToggleFilter() {
    setShowFilterBar((prev) => !prev);
  }
  document.addEventListener("toggle-filter-bar", handleToggleFilter);
  return () => document.removeEventListener("toggle-filter-bar", handleToggleFilter);
}, []);

Step 5: Verify

Run npm run tauri dev. Press / or click the filter button. The filter bar should slide down. Type to search, click labels to filter, use dropdowns. Cards should filter in real-time.

Step 6: Commit

git add src/components/board/FilterBar.tsx src/components/board/BoardView.tsx src/components/board/KanbanColumn.tsx src/components/layout/TopBar.tsx
git commit -m "feat: card filtering and quick search with filter bar"

Task 15: #7 — Keyboard card navigation

Files:

  • Create: src/hooks/useKeyboardNavigation.ts
  • Modify: src/components/board/BoardView.tsx
  • Modify: src/components/board/KanbanColumn.tsx
  • Modify: src/components/board/CardThumbnail.tsx

Step 1: Create useKeyboardNavigation hook

Create src/hooks/useKeyboardNavigation.ts:

import { useState, useEffect, useCallback } from "react";
import type { Board } from "@/types/board";

export function useKeyboardNavigation(
  board: Board | null,
  onOpenCard: (cardId: string) => void
) {
  const [focusedCardId, setFocusedCardId] = useState<string | null>(null);

  const handleKeyDown = useCallback(
    (e: KeyboardEvent) => {
      if (!board) return;
      const tag = (e.target as HTMLElement).tagName;
      if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return;

      const key = e.key.toLowerCase();
      const isNav = ["j", "k", "h", "l", "arrowdown", "arrowup", "arrowleft", "arrowright", "enter", "escape"].includes(key);
      if (!isNav) return;

      e.preventDefault();

      if (key === "escape") {
        setFocusedCardId(null);
        return;
      }

      if (key === "enter" && focusedCardId) {
        onOpenCard(focusedCardId);
        return;
      }

      // Build navigation grid
      const columns = board.columns.filter((c) => !c.collapsed && c.cardIds.length > 0);
      if (columns.length === 0) return;

      // Find current position
      let colIdx = -1;
      let cardIdx = -1;
      if (focusedCardId) {
        for (let ci = 0; ci < columns.length; ci++) {
          const idx = columns[ci].cardIds.indexOf(focusedCardId);
          if (idx !== -1) {
            colIdx = ci;
            cardIdx = idx;
            break;
          }
        }
      }

      // If nothing focused, focus first card
      if (colIdx === -1) {
        setFocusedCardId(columns[0].cardIds[0]);
        return;
      }

      if (key === "j" || key === "arrowdown") {
        const col = columns[colIdx];
        const next = Math.min(cardIdx + 1, col.cardIds.length - 1);
        setFocusedCardId(col.cardIds[next]);
      } else if (key === "k" || key === "arrowup") {
        const col = columns[colIdx];
        const next = Math.max(cardIdx - 1, 0);
        setFocusedCardId(col.cardIds[next]);
      } else if (key === "l" || key === "arrowright") {
        const nextCol = Math.min(colIdx + 1, columns.length - 1);
        const targetIdx = Math.min(cardIdx, columns[nextCol].cardIds.length - 1);
        setFocusedCardId(columns[nextCol].cardIds[targetIdx]);
      } else if (key === "h" || key === "arrowleft") {
        const prevCol = Math.max(colIdx - 1, 0);
        const targetIdx = Math.min(cardIdx, columns[prevCol].cardIds.length - 1);
        setFocusedCardId(columns[prevCol].cardIds[targetIdx]);
      }
    },
    [board, focusedCardId, onOpenCard]
  );

  useEffect(() => {
    document.addEventListener("keydown", handleKeyDown);
    return () => document.removeEventListener("keydown", handleKeyDown);
  }, [handleKeyDown]);

  // Clear focus when a card is removed
  useEffect(() => {
    if (focusedCardId && board && !board.cards[focusedCardId]) {
      setFocusedCardId(null);
    }
  }, [board, focusedCardId]);

  return { focusedCardId, setFocusedCardId };
}

Step 2: Wire hook into BoardView

In src/components/board/BoardView.tsx:

import { useKeyboardNavigation } from "@/hooks/useKeyboardNavigation";

// Inside BoardView:
const { focusedCardId, setFocusedCardId } = useKeyboardNavigation(board, setSelectedCardId);

Pass focusedCardId to KanbanColumn:

<KanbanColumn
  ...
  focusedCardId={focusedCardId}
/>

Clear focus when opening a card (in setSelectedCardId):

function handleCardClick(cardId: string) {
  setSelectedCardId(cardId);
  setFocusedCardId(null);
}

Step 3: Pass isFocused through KanbanColumn to CardThumbnail

In src/components/board/KanbanColumn.tsx, add focusedCardId prop and pass it through:

interface KanbanColumnProps {
  column: Column;
  filteredCardIds?: string[];
  focusedCardId?: string | null;
  onCardClick?: (cardId: string) => void;
  isNew?: boolean;
}

In the card render:

<CardThumbnail
  card={card}
  boardLabels={board?.labels ?? []}
  columnId={column.id}
  onCardClick={onCardClick}
  isFocused={focusedCardId === cardId}
/>

Step 4: Add focus ring to CardThumbnail

In src/components/board/CardThumbnail.tsx, add isFocused prop:

interface CardThumbnailProps {
  card: Card;
  boardLabels: Label[];
  columnId: string;
  onCardClick?: (cardId: string) => void;
  isFocused?: boolean;
}

Add auto-scroll ref and effect:

const cardRef = useRef<HTMLButtonElement>(null);

useEffect(() => {
  if (isFocused && cardRef.current) {
    cardRef.current.scrollIntoView({ block: "nearest", behavior: "smooth" });
  }
}, [isFocused]);

Add focus ring class to the motion.button:

className={`w-full rounded-lg bg-pylon-surface shadow-sm text-left ${
  isFocused ? "ring-2 ring-pylon-accent ring-offset-2 ring-offset-pylon-column" : ""
}`}

Assign the ref (note: need to merge with sortable ref — use useCallback ref or pass to both):

ref={(node) => {
  setNodeRef(node);
  (cardRef as React.MutableRefObject<HTMLButtonElement | null>).current = node;
}}

Step 5: Verify

Run npm run tauri dev. Press J/K to navigate cards vertically, H/L for columns. Focused card should have an accent ring. Enter opens the card, Escape clears focus.

Step 6: Commit

git add src/hooks/useKeyboardNavigation.ts src/components/board/BoardView.tsx src/components/board/KanbanColumn.tsx src/components/board/CardThumbnail.tsx
git commit -m "feat: keyboard card navigation with J/K/H/L and focus ring"

Task 16: #6 — Desktop notifications for due dates

Files:

  • Modify: src-tauri/Cargo.toml
  • Modify: src-tauri/capabilities/default.json
  • Modify: src/stores/app-store.ts
  • Modify: src/types/settings.ts
  • Modify: src/lib/schemas.ts

Step 1: Add tauri-plugin-notification

In src-tauri/Cargo.toml, add to [dependencies]:

tauri-plugin-notification = "2"

Register the plugin in src-tauri/src/lib.rs (find existing .plugin() calls and add):

.plugin(tauri_plugin_notification::init())

In src-tauri/capabilities/default.json, add to permissions array:

"notification:default"

Install the npm package:

npm install @tauri-apps/plugin-notification

Step 2: Add lastNotificationCheck to settings

In src/types/settings.ts, add to AppSettings:

lastNotificationCheck: string | null;

In src/lib/schemas.ts, add to appSettingsSchema:

lastNotificationCheck: z.string().nullable().default(null),

In src/stores/app-store.ts, add to default settings:

lastNotificationCheck: null,

Step 3: Add notification check to init

In src/stores/app-store.ts, import and add notification logic to init:

import { isPermissionGranted, requestPermission, sendNotification } from "@tauri-apps/plugin-notification";

After set({ settings, boards, initialized: true }) in init, add:

// Due date notifications (once per hour)
const lastCheck = settings.lastNotificationCheck;
const hourAgo = new Date(Date.now() - 60 * 60 * 1000).toISOString();
if (!lastCheck || lastCheck < hourAgo) {
  try {
    let granted = await isPermissionGranted();
    if (!granted) {
      const perm = await requestPermission();
      granted = perm === "granted";
    }
    if (granted) {
      // Scan all boards for due cards
      let dueToday = 0;
      let overdue = 0;
      const today = new Date();
      const todayStr = today.toDateString();

      for (const meta of boards) {
        try {
          const board = await loadBoard(meta.id);
          for (const card of Object.values(board.cards)) {
            if (!card.dueDate) continue;
            const due = new Date(card.dueDate);
            if (due.toDateString() === todayStr) dueToday++;
            else if (due < today) overdue++;
          }
        } catch { /* skip */ }
      }

      if (dueToday > 0) {
        sendNotification({ title: "OpenPylon", body: `You have ${dueToday} card${dueToday > 1 ? "s" : ""} due today` });
      }
      if (overdue > 0) {
        sendNotification({ title: "OpenPylon", body: `You have ${overdue} overdue card${overdue > 1 ? "s" : ""}` });
      }
    }
    updateAndSave(get, set, { lastNotificationCheck: new Date().toISOString() });
  } catch { /* notification plugin not available */ }
}

Note: loadBoard needs to be imported (it should already be accessible — check the import from storage).

Step 4: Verify

Run npm run tauri dev (requires cargo build for the new plugin). With cards that have due dates set to today or in the past, a system notification should appear.

Step 5: Commit

git add src-tauri/Cargo.toml src-tauri/src/lib.rs src-tauri/capabilities/default.json src/stores/app-store.ts src/types/settings.ts src/lib/schemas.ts package.json package-lock.json
git commit -m "feat: desktop notifications for due/overdue cards"

Task 17: #13 — Card comments / activity log

Files:

  • Create: src/components/card-detail/CommentsSection.tsx
  • Modify: src/stores/board-store.ts
  • Modify: src/components/card-detail/CardDetailModal.tsx

Step 1: Add comment store actions

In src/stores/board-store.ts, add to BoardActions:

addComment: (cardId: string, text: string) => void;
deleteComment: (cardId: string, commentId: string) => void;

Add implementations:

addComment: (cardId, text) => {
  mutate(get, set, (b) => {
    const card = b.cards[cardId];
    if (!card) return b;
    const comment = { id: ulid(), text, createdAt: now() };
    return {
      ...b,
      updatedAt: now(),
      cards: {
        ...b.cards,
        [cardId]: {
          ...card,
          comments: [comment, ...card.comments],
          updatedAt: now(),
        },
      },
    };
  });
},

deleteComment: (cardId, commentId) => {
  mutate(get, set, (b) => {
    const card = b.cards[cardId];
    if (!card) return b;
    return {
      ...b,
      updatedAt: now(),
      cards: {
        ...b.cards,
        [cardId]: {
          ...card,
          comments: card.comments.filter((c) => c.id !== commentId),
          updatedAt: now(),
        },
      },
    };
  });
},

Step 2: Create CommentsSection component

Create src/components/card-detail/CommentsSection.tsx:

import { useState, useRef } from "react";
import { formatDistanceToNow } from "date-fns";
import { X } from "lucide-react";
import { OverlayScrollbarsComponent } from "overlayscrollbars-react";
import { Button } from "@/components/ui/button";
import { useBoardStore } from "@/stores/board-store";
import type { Comment } from "@/types/board";

interface CommentsSectionProps {
  cardId: string;
  comments: Comment[];
}

export function CommentsSection({ cardId, comments }: CommentsSectionProps) {
  const addComment = useBoardStore((s) => s.addComment);
  const deleteComment = useBoardStore((s) => s.deleteComment);
  const [draft, setDraft] = useState("");
  const textareaRef = useRef<HTMLTextAreaElement>(null);

  function handleAdd() {
    const trimmed = draft.trim();
    if (!trimmed) return;
    addComment(cardId, trimmed);
    setDraft("");
    textareaRef.current?.focus();
  }

  function handleKeyDown(e: React.KeyboardEvent) {
    if (e.key === "Enter" && !e.shiftKey) {
      e.preventDefault();
      handleAdd();
    }
  }

  return (
    <div className="flex flex-col gap-2">
      <h4 className="font-mono text-xs uppercase tracking-widest text-pylon-text-secondary">
        Comments
      </h4>

      {/* Add comment */}
      <div className="flex gap-2">
        <textarea
          ref={textareaRef}
          value={draft}
          onChange={(e) => setDraft(e.target.value)}
          onKeyDown={handleKeyDown}
          placeholder="Add a comment... (Enter to send, Shift+Enter for newline)"
          rows={2}
          className="flex-1 resize-none rounded-md bg-pylon-column px-2 py-1.5 text-sm text-pylon-text outline-none placeholder:text-pylon-text-secondary/60 focus:ring-1 focus:ring-pylon-accent"
        />
        <Button
          size="sm"
          onClick={handleAdd}
          disabled={!draft.trim()}
          className="self-end"
        >
          Add
        </Button>
      </div>

      {/* Comment list */}
      {comments.length > 0 && (
        <OverlayScrollbarsComponent
          className="max-h-[200px]"
          options={{ scrollbars: { theme: "os-theme-pylon", autoHide: "scroll", autoHideDelay: 600, clickScroll: true }, overflow: { x: "hidden" } }}
          defer
        >
          <div className="flex flex-col gap-2">
            {comments.map((comment) => (
              <div
                key={comment.id}
                className="group/comment flex gap-2 rounded px-2 py-1.5 hover:bg-pylon-column/60"
              >
                <div className="flex-1">
                  <p className="whitespace-pre-wrap text-sm text-pylon-text">
                    {comment.text}
                  </p>
                  <span className="font-mono text-[10px] text-pylon-text-secondary">
                    {formatDistanceToNow(new Date(comment.createdAt), { addSuffix: true })}
                  </span>
                </div>
                <button
                  onClick={() => deleteComment(cardId, comment.id)}
                  className="shrink-0 self-start rounded p-0.5 text-pylon-text-secondary opacity-0 transition-opacity hover:bg-pylon-danger/10 hover:text-pylon-danger group-hover/comment:opacity-100"
                  aria-label="Delete comment"
                >
                  <X className="size-3" />
                </button>
              </div>
            ))}
          </div>
        </OverlayScrollbarsComponent>
      )}
    </div>
  );
}

Step 3: Add CommentsSection to CardDetailModal

In src/components/card-detail/CardDetailModal.tsx, import:

import { CommentsSection } from "@/components/card-detail/CommentsSection";

Add a new full-width row at the bottom of the grid (after attachments):

{/* Row 5: Comments (full width) */}
<motion.div
  className="col-span-2 rounded-lg bg-pylon-column/50 p-4"
  variants={fadeSlideUp}
  transition={springs.bouncy}
>
  <CommentsSection cardId={cardId} comments={card.comments} />
</motion.div>

Step 4: Verify

Run npm run tauri dev. Open a card. Comments section should appear at the bottom. Add a comment with Enter, see it appear with relative timestamp. Delete with X on hover.

Step 5: Commit

git add src/components/card-detail/CommentsSection.tsx src/stores/board-store.ts src/components/card-detail/CardDetailModal.tsx
git commit -m "feat: card comments with add/delete and timestamps"

Phase 4: System Features & Infrastructure


Task 18: #14 — Board templates & saved structures

Files:

  • Create: src/types/template.ts
  • Modify: src/lib/storage.ts
  • Modify: src/lib/board-factory.ts
  • Modify: src/components/boards/BoardCard.tsx (add "Save as Template" menu item)
  • Modify: src/components/boards/BoardList.tsx (update NewBoardDialog to show templates)

Step 1: Create template type

Create src/types/template.ts:

import type { ColumnWidth, Label, BoardSettings } from "./board";

export interface BoardTemplate {
  id: string;
  name: string;
  color: string;
  columns: {
    title: string;
    width: ColumnWidth;
    color: string | null;
    wipLimit: number | null;
  }[];
  labels: Label[];
  settings: BoardSettings;
}

Step 2: Add template storage functions

In src/lib/storage.ts, add a templates directory helper and CRUD:

async function getTemplatesDir(): Promise<string> {
  const base = await getBaseDir();
  return join(base, "templates");
}

Update ensureDataDirs to create templates dir:

const templatesDir = await getTemplatesDir();
if (!(await exists(templatesDir))) {
  await mkdir(templatesDir, { recursive: true });
}

Add template functions:

export async function listTemplates(): Promise<BoardTemplate[]> {
  const dir = await getTemplatesDir();
  if (!(await exists(dir))) return [];
  const entries = await readDir(dir);
  const templates: BoardTemplate[] = [];
  for (const entry of entries) {
    if (!entry.name || !entry.name.endsWith(".json")) continue;
    try {
      const filePath = await join(dir, entry.name);
      const raw = await readTextFile(filePath);
      templates.push(JSON.parse(raw));
    } catch { continue; }
  }
  return templates;
}

export async function saveTemplate(template: BoardTemplate): Promise<void> {
  const dir = await getTemplatesDir();
  const filePath = await join(dir, `${template.id}.json`);
  await writeTextFile(filePath, JSON.stringify(template, null, 2));
}

export async function deleteTemplate(templateId: string): Promise<void> {
  const dir = await getTemplatesDir();
  const filePath = await join(dir, `${templateId}.json`);
  if (await exists(filePath)) {
    await remove(filePath);
  }
}

Import the type:

import type { BoardTemplate } from "@/types/template";

Step 3: Add createBoardFromTemplate to board-factory

In src/lib/board-factory.ts:

import type { BoardTemplate } from "@/types/template";

export function createBoardFromTemplate(template: BoardTemplate, title: string): Board {
  const ts = new Date().toISOString();
  return {
    id: ulid(),
    title,
    color: template.color,
    createdAt: ts,
    updatedAt: ts,
    columns: template.columns.map((c) => ({
      id: ulid(),
      title: c.title,
      cardIds: [],
      width: c.width,
      color: c.color,
      collapsed: false,
      wipLimit: c.wipLimit,
    })),
    cards: {},
    labels: template.labels.map((l) => ({ ...l, id: ulid() })),
    settings: { ...template.settings },
  };
}

Step 4: Add "Save as Template" to BoardCard context menu

In src/components/boards/BoardCard.tsx, add a template save handler:

import { listTemplates, saveTemplate, loadBoard, saveBoard, deleteBoard } from "@/lib/storage";
import type { BoardTemplate } from "@/types/template";

async function handleSaveAsTemplate() {
  const full = await loadBoard(board.id);
  const { ulid } = await import("ulid");
  const template: BoardTemplate = {
    id: ulid(),
    name: full.title,
    color: full.color,
    columns: full.columns.map((c) => ({
      title: c.title,
      width: c.width,
      color: c.color,
      wipLimit: c.wipLimit,
    })),
    labels: full.labels,
    settings: full.settings,
  };
  await saveTemplate(template);
  addToast(`Template "${full.title}" saved`, "success");
}

Add menu item in the context menu (after Duplicate):

<ContextMenuItem onClick={handleSaveAsTemplate}>
  <Bookmark className="size-4" />
  Save as Template
</ContextMenuItem>

Import Bookmark from lucide-react.

Step 5: Update NewBoardDialog to show templates

The NewBoardDialog component needs to be found/updated to load templates from storage and show them as options alongside the built-in Blank/Kanban/Sprint templates. User templates get a delete button. When selected, use createBoardFromTemplate instead of createBoard.

This involves finding the new board dialog (likely in BoardList.tsx) and adding template support. The exact implementation depends on the existing dialog structure — load templates with useEffect + listTemplates(), display as a grid of clickable options.

Step 6: Verify

Run npm run tauri dev. Right-click a board card > "Save as Template". Create a new board — the saved template should appear as an option.

Step 7: Commit

git add src/types/template.ts src/lib/storage.ts src/lib/board-factory.ts src/components/boards/BoardCard.tsx src/components/boards/BoardList.tsx
git commit -m "feat: board templates - save and create from templates"

Task 19: #15 — Auto-backup & version history

Files:

  • Modify: src/lib/storage.ts
  • Modify: src/stores/board-store.ts
  • Create: src/components/board/VersionHistoryDialog.tsx
  • Modify: src/components/layout/TopBar.tsx

Step 1: Add backup storage functions

In src/lib/storage.ts, add:

async function getBackupsDir(boardId: string): Promise<string> {
  const base = await getBaseDir();
  return join(base, "backups", boardId);
}

export interface BackupEntry {
  filename: string;
  timestamp: string;
  cardCount: number;
  columnCount: number;
}

export async function listBackups(boardId: string): Promise<BackupEntry[]> {
  const dir = await getBackupsDir(boardId);
  if (!(await exists(dir))) return [];
  const entries = await readDir(dir);
  const backups: BackupEntry[] = [];
  for (const entry of entries) {
    if (!entry.name || !entry.name.endsWith(".json")) continue;
    try {
      const filePath = await join(dir, entry.name);
      const raw = await readTextFile(filePath);
      const data = JSON.parse(raw);
      const board = boardSchema.parse(data);
      // Extract timestamp from filename: {boardId}-{ISO}.json
      const isoMatch = entry.name.match(/\d{4}-\d{2}-\d{2}T[\d:.]+Z/);
      backups.push({
        filename: entry.name,
        timestamp: isoMatch ? isoMatch[0] : board.updatedAt,
        cardCount: Object.keys(board.cards).length,
        columnCount: board.columns.length,
      });
    } catch { continue; }
  }
  backups.sort((a, b) => b.timestamp.localeCompare(a.timestamp));
  return backups;
}

export async function createBackup(board: Board): Promise<void> {
  const dir = await getBackupsDir(board.id);
  if (!(await exists(dir))) {
    await mkdir(dir, { recursive: true });
  }
  const ts = new Date().toISOString().replace(/:/g, "-");
  const filename = `${board.id}-${ts}.json`;
  const filePath = await join(dir, filename);
  await writeTextFile(filePath, JSON.stringify(board, null, 2));
}

export async function pruneBackups(boardId: string, keep: number = 10): Promise<void> {
  const backups = await listBackups(boardId);
  if (backups.length <= keep) return;
  const dir = await getBackupsDir(boardId);
  const toDelete = backups.slice(keep);
  for (const backup of toDelete) {
    try {
      const filePath = await join(dir, backup.filename);
      await remove(filePath);
    } catch { /* skip */ }
  }
}

export async function restoreBackupFile(boardId: string, filename: string): Promise<Board> {
  const dir = await getBackupsDir(boardId);
  const filePath = await join(dir, filename);
  const raw = await readTextFile(filePath);
  const data = JSON.parse(raw);
  const board = boardSchema.parse(data) as Board;
  return board;
}

Step 2: Integrate auto-backup into saveBoard

Update saveBoard in src/lib/storage.ts to create timestamped backups:

export async function saveBoard(board: Board): Promise<void> {
  const boardsDir = await getBoardsDir();
  const filePath = await boardFilePath(boardsDir, board.id);
  const backupPath = await boardBackupPath(boardsDir, board.id);

  // Rotate previous version to backup
  if (await exists(filePath)) {
    try {
      const previous = await readTextFile(filePath);
      await writeTextFile(backupPath, previous);
      // Also create a timestamped backup
      await createBackup(JSON.parse(previous) as Board);
      await pruneBackups(board.id);
    } catch {
      // If we can't create a backup, continue saving anyway
    }
  }

  await writeTextFile(filePath, JSON.stringify(board, null, 2));
}

Note: This will create many backups since saveBoard is called on every debounced change. To avoid excessive backups, add a throttle — only create a timestamped backup if the last one is more than 5 minutes old:

// Only create timestamped backup if last backup > 5 min ago
const backups = await listBackups(board.id);
const recentThreshold = new Date(Date.now() - 5 * 60 * 1000).toISOString();
if (backups.length === 0 || backups[0].timestamp < recentThreshold) {
  await createBackup(JSON.parse(previous) as Board);
  await pruneBackups(board.id);
}

Step 3: Create VersionHistoryDialog

Create src/components/board/VersionHistoryDialog.tsx:

import { useState, useEffect } from "react";
import { formatDistanceToNow } from "date-fns";
import { OverlayScrollbarsComponent } from "overlayscrollbars-react";
import {
  Dialog,
  DialogContent,
  DialogHeader,
  DialogTitle,
  DialogDescription,
  DialogFooter,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { listBackups, restoreBackupFile, saveBoard, type BackupEntry } from "@/lib/storage";
import { useBoardStore } from "@/stores/board-store";

interface VersionHistoryDialogProps {
  open: boolean;
  onOpenChange: (open: boolean) => void;
}

export function VersionHistoryDialog({ open, onOpenChange }: VersionHistoryDialogProps) {
  const board = useBoardStore((s) => s.board);
  const [backups, setBackups] = useState<BackupEntry[]>([]);
  const [confirmRestore, setConfirmRestore] = useState<BackupEntry | null>(null);

  useEffect(() => {
    if (open && board) {
      listBackups(board.id).then(setBackups);
    }
  }, [open, board]);

  async function handleRestore(backup: BackupEntry) {
    if (!board) return;
    // Back up current state before restoring
    await saveBoard(board);
    const restored = await restoreBackupFile(board.id, backup.filename);
    await saveBoard(restored);
    // Reload
    await useBoardStore.getState().openBoard(board.id);
    setConfirmRestore(null);
    onOpenChange(false);
  }

  return (
    <>
      <Dialog open={open && !confirmRestore} onOpenChange={onOpenChange}>
        <DialogContent className="bg-pylon-surface sm:max-w-md">
          <DialogHeader>
            <DialogTitle className="font-heading text-pylon-text">
              Version History
            </DialogTitle>
            <DialogDescription className="text-pylon-text-secondary">
              Browse and restore previous versions of this board.
            </DialogDescription>
          </DialogHeader>
          <OverlayScrollbarsComponent
            className="max-h-[300px]"
            options={{ scrollbars: { theme: "os-theme-pylon", autoHide: "scroll", autoHideDelay: 600, clickScroll: true }, overflow: { x: "hidden" } }}
            defer
          >
            {backups.length > 0 ? (
              <div className="flex flex-col gap-1">
                {backups.map((backup) => (
                  <div
                    key={backup.filename}
                    className="flex items-center justify-between rounded px-3 py-2 hover:bg-pylon-column/60"
                  >
                    <div className="flex flex-col">
                      <span className="text-sm text-pylon-text">
                        {formatDistanceToNow(new Date(backup.timestamp), { addSuffix: true })}
                      </span>
                      <span className="font-mono text-xs text-pylon-text-secondary">
                        {backup.cardCount} cards, {backup.columnCount} columns
                      </span>
                    </div>
                    <Button
                      variant="ghost"
                      size="sm"
                      onClick={() => setConfirmRestore(backup)}
                      className="text-pylon-accent"
                    >
                      Restore
                    </Button>
                  </div>
                ))}
              </div>
            ) : (
              <p className="px-3 py-6 text-center text-sm text-pylon-text-secondary">
                No backups yet. Backups are created automatically as you work.
              </p>
            )}
          </OverlayScrollbarsComponent>
        </DialogContent>
      </Dialog>

      {/* Restore confirmation */}
      <Dialog open={confirmRestore != null} onOpenChange={() => setConfirmRestore(null)}>
        <DialogContent className="bg-pylon-surface sm:max-w-sm">
          <DialogHeader>
            <DialogTitle className="font-heading text-pylon-text">
              Restore Version
            </DialogTitle>
            <DialogDescription className="text-pylon-text-secondary">
              This will replace the current board with the selected version. Your current state will be backed up first.
            </DialogDescription>
          </DialogHeader>
          <DialogFooter>
            <Button variant="ghost" onClick={() => setConfirmRestore(null)} className="text-pylon-text-secondary">
              Cancel
            </Button>
            <Button onClick={() => confirmRestore && handleRestore(confirmRestore)}>
              Restore
            </Button>
          </DialogFooter>
        </DialogContent>
      </Dialog>
    </>
  );
}

Step 4: Add Version History to TopBar

In src/components/layout/TopBar.tsx, add a "Version History" menu item to the board settings dropdown:

Import and add state:

import { VersionHistoryDialog } from "@/components/board/VersionHistoryDialog";

In TopBar component:

const [showVersionHistory, setShowVersionHistory] = useState(false);

Add menu item inside the DropdownMenuContent for board settings (after the Attachments submenu):

<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => setShowVersionHistory(true)}>
  Version History
</DropdownMenuItem>

Import DropdownMenuSeparator and DropdownMenuItem (add to existing import).

Render the dialog at the bottom of the component return:

{isBoardView && (
  <VersionHistoryDialog
    open={showVersionHistory}
    onOpenChange={setShowVersionHistory}
  />
)}

Step 5: Ensure backups directory is created

In src/lib/storage.ts, update ensureDataDirs to create the backups directory:

const backupsDir = await join(base, "backups");
if (!(await exists(backupsDir))) {
  await mkdir(backupsDir, { recursive: true });
}

Step 6: Verify

Run npm run tauri dev. Make changes to a board. Open board settings > Version History. Backups should be listed. Click Restore on one and confirm.

Step 7: Commit

git add src/lib/storage.ts src/stores/board-store.ts src/components/board/VersionHistoryDialog.tsx src/components/layout/TopBar.tsx
git commit -m "feat: auto-backup and version history with restore"

Summary

Phase Tasks Features
0 1-3 Comment type, Card priority+comments, Column collapsed+wipLimit
1 4-7 defaultColumnWidth, due date colors, card aging, open attachments
2 8-13 Priority UI, context menu, WIP limits, collapse columns, checklist reorder
3 14-17 Filter bar, keyboard nav, notifications, comments
4 18-19 Templates, auto-backup + version history

Each phase is independently shippable. The app remains functional after completing any phase.