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