auto-claude: 153-widen-kanban-columns-and-add-collapse-feature (#1457)

* auto-claude: subtask-1-1 - Create kanban-settings-store.ts with column preferences

Create Zustand store for kanban column preferences (width, collapsed, locked)
with localStorage persistence following task-store.ts patterns:

- ColumnPreferences interface with width, isCollapsed, isLocked fields
- KanbanColumnPreferences type mapping each TaskStatusColumn to preferences
- Load/save/reset functions with localStorage persistence
- Validation of stored data structure before use
- Helper functions: getEffectiveColumnWidth, hasAnyCollapsedColumn, etc.
- Constants for DEFAULT_COLUMN_WIDTH (320px), MIN/MAX bounds (180-600px)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* auto-claude: subtask-2-1 - Add English translations for collapse/expand/resize/lock

* auto-claude: subtask-2-2 - Add French translations for collapse/expand/resize

Added French translations for kanban column collapse/expand feature:
- collapseColumn: "Réduire la colonne"
- expandColumn: "Développer la colonne"
- resizeColumn: "Redimensionner la colonne"
- lockColumn: "Verrouiller la largeur de la colonne"
- unlockColumn: "Déverrouiller la largeur de la colonne"
- columnLocked: "La largeur de la colonne est verrouillée"
- expandAll: "Développer toutes les colonnes"

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* auto-claude: subtask-3-1 - Add CSS classes for kanban column resize/collapse features

Add comprehensive CSS classes in globals.css for:
- .kanban-resize-handle - Drag handle on right edge of columns
- .kanban-column-collapsed - Collapsed column styling with 48px width
- .kanban-column-collapsed-title - Rotated vertical title text
- .kanban-column-collapsed-header - Collapsed column layout
- .kanban-column-collapsed-count - Task count badge
- .kanban-column-locked - Lock indicator and disabled resize state
- .kanban-lock-indicator - Lock icon button styling
- .kanban-column-transition - Smooth width transitions
- .kanban-column-no-transition - Disable transition during drag
- .kanban-resizing - Body state during resize
- .kanban-column-expand-btn / .kanban-column-collapse-btn - Toggle buttons
- .drop-zone-highlight variant for collapsed columns
- Reduced motion support for all transitions

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* auto-claude: subtask-4-1 - Update DroppableColumn default width from min-w-72 (288px) to min-w-80 (320px) and integrate kanban-settings-store import

* auto-claude: subtask-4-2 - Add collapse/expand functionality: collapse button

- Added ChevronLeft/ChevronRight icons for collapse/expand buttons
- Added isCollapsed and onToggleCollapsed props to DroppableColumnProps
- Implemented collapsed state rendering: narrow 48px vertical strip with rotated title and task count
- Added collapse button in expanded column header (left side)
- Added expand button in collapsed column header (top)
- Connected to kanban-settings-store for persisting collapse state
- Added handleToggleColumnCollapsed callback that saves preferences after toggle

* auto-claude: subtask-4-3 - Add resize functionality: drag handle on right edge

- Add resize handle on right edge of each DroppableColumn
- Implement mouse drag to resize between 180px-600px (MIN/MAX from store)
- Add visual feedback during drag (cursor: col-resize, highlight on handle)
- Integrate with kanban-settings-store for width persistence
- Support touch events for mobile compatibility
- Document-level event listeners for smooth dragging experience

* auto-claude: subtask-4-4 - Add lock functionality: lock toggle button in colu

* auto-claude: subtask-5-1 - Verify drag-and-drop on collapsed columns and add Expand All

- Verified collapsed columns properly accept task drops via useDroppable hook
- Verified drop zone highlighting works on collapsed columns (isOver && 'drop-zone-highlight')
- Added 'Expand All' button that appears when 3+ columns are collapsed
- Added ChevronsRight icon for the Expand All button
- Added collapsedColumnCount useMemo to track collapsed columns
- Added handleExpandAll callback to expand all columns and persist preferences
- Added setColumnCollapsed action to store hooks for explicit collapse control

* fix: address PR review findings for kanban column features

- Replace magic numbers (180, 600) with MIN/MAX_COLUMN_WIDTH constants
- Remove unused onResize prop from DroppableColumn component
- Fix stale closure risk by capturing projectId at resize start
- Remove unused utility functions from kanban-settings-store
- Remove ~250 lines of unused CSS classes (component uses Tailwind)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: capture projectId at function start to prevent race conditions

Apply consistent pattern across collapse/expand/lock handlers:
- handleToggleColumnCollapsed: capture projectId before setTimeout
- handleExpandAll: capture projectId before setTimeout
- handleToggleColumnLocked: capture projectId before setTimeout

This matches the safer pattern already used in handleResizeStart.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Andy
2026-01-25 11:29:21 +01:00
committed by GitHub
parent 783f0fe0e4
commit d659730751
5 changed files with 677 additions and 26 deletions
@@ -19,7 +19,7 @@ import {
sortableKeyboardCoordinates,
verticalListSortingStrategy
} from '@dnd-kit/sortable';
import { Plus, Inbox, Loader2, Eye, CheckCircle2, Archive, RefreshCw, GitPullRequest, X, Settings, ListPlus } from 'lucide-react';
import { Plus, Inbox, Loader2, Eye, CheckCircle2, Archive, RefreshCw, GitPullRequest, X, Settings, ListPlus, ChevronLeft, ChevronRight, ChevronsRight, Lock, Unlock } from 'lucide-react';
import { Checkbox } from './ui/checkbox';
import { ScrollArea } from './ui/scroll-area';
import { Button } from './ui/button';
@@ -31,6 +31,7 @@ import { TASK_STATUS_COLUMNS, TASK_STATUS_LABELS } from '../../shared/constants'
import { cn } from '../lib/utils';
import { persistTaskStatus, forceCompleteTask, archiveTasks, useTaskStore } from '../stores/task-store';
import { updateProjectSettings, useProjectStore } from '../stores/project-store';
import { useKanbanSettingsStore, COLLAPSED_COLUMN_WIDTH, DEFAULT_COLUMN_WIDTH, MIN_COLUMN_WIDTH, MAX_COLUMN_WIDTH } from '../stores/kanban-settings-store';
import { useToast } from '../hooks/use-toast';
import { WorktreeCleanupDialog } from './WorktreeCleanupDialog';
import { BulkPRDialog } from './BulkPRDialog';
@@ -81,6 +82,17 @@ interface DroppableColumnProps {
onSelectAll?: () => void;
onDeselectAll?: () => void;
onToggleSelect?: (taskId: string) => void;
// Collapse props
isCollapsed?: boolean;
onToggleCollapsed?: () => void;
// Resize props
columnWidth?: number;
isResizing?: boolean;
onResizeStart?: (startX: number) => void;
onResizeEnd?: () => void;
// Lock props
isLocked?: boolean;
onToggleLocked?: () => void;
}
/**
@@ -130,6 +142,14 @@ function droppableColumnPropsAreEqual(
if (prevProps.onSelectAll !== nextProps.onSelectAll) return false;
if (prevProps.onDeselectAll !== nextProps.onDeselectAll) return false;
if (prevProps.onToggleSelect !== nextProps.onToggleSelect) return false;
if (prevProps.isCollapsed !== nextProps.isCollapsed) return false;
if (prevProps.onToggleCollapsed !== nextProps.onToggleCollapsed) return false;
if (prevProps.columnWidth !== nextProps.columnWidth) return false;
if (prevProps.isResizing !== nextProps.isResizing) return false;
if (prevProps.onResizeStart !== nextProps.onResizeStart) return false;
if (prevProps.onResizeEnd !== nextProps.onResizeEnd) return false;
if (prevProps.isLocked !== nextProps.isLocked) return false;
if (prevProps.onToggleLocked !== nextProps.onToggleLocked) return false;
// Compare selection props
const prevSelected = prevProps.selectedTaskIds;
@@ -200,7 +220,7 @@ const getEmptyStateContent = (status: TaskStatus, t: (key: string) => string): {
}
};
const DroppableColumn = memo(function DroppableColumn({ status, tasks, onTaskClick, onStatusChange, isOver, onAddClick, onArchiveAll, onQueueSettings, onQueueAll, maxParallelTasks, archivedCount, showArchived, onToggleArchived, selectedTaskIds, onSelectAll, onDeselectAll, onToggleSelect }: DroppableColumnProps) {
const DroppableColumn = memo(function DroppableColumn({ status, tasks, onTaskClick, onStatusChange, isOver, onAddClick, onArchiveAll, onQueueSettings, onQueueAll, maxParallelTasks, archivedCount, showArchived, onToggleArchived, selectedTaskIds, onSelectAll, onDeselectAll, onToggleSelect, isCollapsed, onToggleCollapsed, columnWidth, isResizing, onResizeStart, onResizeEnd, isLocked, onToggleLocked }: DroppableColumnProps) {
const { t } = useTranslation(['tasks', 'common']);
const { setNodeRef } = useDroppable({
id: status
@@ -298,19 +318,94 @@ const DroppableColumn = memo(function DroppableColumn({ status, tasks, onTaskCli
const emptyState = getEmptyStateContent(status, t);
// Collapsed state: show narrow vertical strip with rotated title and task count
if (isCollapsed) {
return (
<div
ref={setNodeRef}
className={cn(
'flex flex-col rounded-xl border border-white/5 bg-linear-to-b from-secondary/30 to-transparent backdrop-blur-sm transition-all duration-200',
getColumnBorderColor(),
'border-t-2',
isOver && 'drop-zone-highlight'
)}
style={{ width: COLLAPSED_COLUMN_WIDTH, minWidth: COLLAPSED_COLUMN_WIDTH, maxWidth: COLLAPSED_COLUMN_WIDTH }}
>
{/* Expand button at top */}
<div className="flex justify-center p-2 border-b border-white/5">
<Tooltip delayDuration={200}>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 hover:bg-primary/10 hover:text-primary transition-colors"
onClick={onToggleCollapsed}
aria-label={t('kanban.expandColumn')}
>
<ChevronRight className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="right">
{t('kanban.expandColumn')}
</TooltipContent>
</Tooltip>
</div>
{/* Rotated title and task count */}
<div className="flex-1 flex flex-col items-center justify-center">
<div
className="flex items-center gap-2 whitespace-nowrap"
style={{ writingMode: 'vertical-rl', transform: 'rotate(180deg)' }}
>
<span className="column-count-badge">
{tasks.length}
</span>
<h2 className="font-semibold text-sm text-foreground">
{t(TASK_STATUS_LABELS[status])}
</h2>
</div>
</div>
</div>
);
}
return (
<div
ref={setNodeRef}
className={cn(
'flex min-w-72 max-w-[30rem] flex-1 flex-col rounded-xl border border-white/5 bg-linear-to-b from-secondary/30 to-transparent backdrop-blur-sm transition-all duration-200',
getColumnBorderColor(),
'border-t-2',
isOver && 'drop-zone-highlight'
)}
className="relative flex"
style={columnWidth ? { width: columnWidth, minWidth: MIN_COLUMN_WIDTH, maxWidth: MAX_COLUMN_WIDTH, flexShrink: 0 } : undefined}
>
{/* Column header - enhanced styling */}
<div className="flex items-center justify-between p-4 border-b border-white/5">
<div
ref={setNodeRef}
className={cn(
'flex flex-1 flex-col rounded-xl border border-white/5 bg-linear-to-b from-secondary/30 to-transparent backdrop-blur-sm transition-all duration-200',
!columnWidth && 'min-w-80 max-w-[30rem]',
getColumnBorderColor(),
'border-t-2',
isOver && 'drop-zone-highlight'
)}
>
{/* Column header - enhanced styling */}
<div className="flex items-center justify-between p-4 border-b border-white/5">
<div className="flex items-center gap-2.5">
{/* Collapse button */}
{onToggleCollapsed && (
<Tooltip delayDuration={200}>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 hover:bg-muted-foreground/10 hover:text-muted-foreground transition-colors"
onClick={onToggleCollapsed}
aria-label={t('kanban.collapseColumn')}
>
<ChevronLeft className="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>
{t('kanban.collapseColumn')}
</TooltipContent>
</Tooltip>
)}
{/* Select All checkbox for human_review column */}
{isHumanReview && onSelectAll && onDeselectAll && (
<Tooltip delayDuration={200}>
@@ -347,6 +442,31 @@ const DroppableColumn = memo(function DroppableColumn({ status, tasks, onTaskCli
)}
</div>
<div className="flex items-center gap-1">
{/* Lock toggle button - available for all columns */}
{onToggleLocked && (
<Tooltip delayDuration={200}>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className={cn(
'h-7 w-7 transition-colors',
isLocked
? 'text-amber-500 bg-amber-500/10 hover:bg-amber-500/20'
: 'hover:bg-muted-foreground/10 hover:text-muted-foreground'
)}
onClick={onToggleLocked}
aria-pressed={isLocked}
aria-label={isLocked ? t('kanban.unlockColumn') : t('kanban.lockColumn')}
>
{isLocked ? <Lock className="h-3.5 w-3.5" /> : <Unlock className="h-3.5 w-3.5" />}
</Button>
</TooltipTrigger>
<TooltipContent>
{isLocked ? t('kanban.unlockColumn') : t('kanban.lockColumn')}
</TooltipContent>
</Tooltip>
)}
{status === 'backlog' && (
<>
{onQueueAll && tasks.length > 0 && (
@@ -468,6 +588,39 @@ const DroppableColumn = memo(function DroppableColumn({ status, tasks, onTaskCli
</SortableContext>
</ScrollArea>
</div>
</div>
{/* Resize handle on right edge */}
{onResizeStart && onResizeEnd && (
<div
className={cn(
"absolute right-0 top-0 bottom-0 w-1 touch-none z-10",
"transition-colors duration-150",
isLocked
? "cursor-not-allowed bg-amber-500/20 hover:bg-amber-500/30"
: "cursor-col-resize hover:bg-primary/40",
isResizing && !isLocked && "bg-primary/60"
)}
onMouseDown={(e) => {
e.preventDefault();
// Don't start resize if column is locked
if (isLocked) return;
onResizeStart(e.clientX);
}}
onTouchStart={(e) => {
e.preventDefault();
// Don't start resize if column is locked
if (isLocked) return;
if (e.touches.length > 0) {
onResizeStart(e.touches[0].clientX);
}
}}
title={isLocked ? t('kanban.columnLocked') : undefined}
>
{/* Wider invisible hit area for easier grabbing */}
<div className="absolute inset-y-0 -left-1 -right-1" />
</div>
)}
</div>
);
}, droppableColumnPropsAreEqual);
@@ -482,6 +635,22 @@ export function KanbanBoard({ tasks, onTaskClick, onNewTaskClick, onRefresh, isR
// Project store for queue settings
const projects = useProjectStore((state) => state.projects);
// Kanban settings store for column preferences (collapse state, width, lock state)
const columnPreferences = useKanbanSettingsStore((state) => state.columnPreferences);
const loadKanbanPreferences = useKanbanSettingsStore((state) => state.loadPreferences);
const saveKanbanPreferences = useKanbanSettingsStore((state) => state.savePreferences);
const toggleColumnCollapsed = useKanbanSettingsStore((state) => state.toggleColumnCollapsed);
const setColumnCollapsed = useKanbanSettingsStore((state) => state.setColumnCollapsed);
const setColumnWidth = useKanbanSettingsStore((state) => state.setColumnWidth);
const toggleColumnLocked = useKanbanSettingsStore((state) => state.toggleColumnLocked);
// Column resize state
const [resizingColumn, setResizingColumn] = useState<typeof TASK_STATUS_COLUMNS[number] | null>(null);
const resizeStartX = useRef<number>(0);
const resizeStartWidth = useRef<number>(0);
// Capture projectId at resize start to avoid stale closure if project changes during resize
const resizeProjectIdRef = useRef<string | null>(null);
// Get projectId from first task
const projectId = tasks[0]?.projectId;
const project = projectId ? projects.find((p) => p.id === projectId) : undefined;
@@ -524,6 +693,14 @@ export function KanbanBoard({ tasks, onTaskClick, onNewTaskClick, onRefresh, isR
[tasks]
);
// Calculate collapsed column count for "Expand All" button
const collapsedColumnCount = useMemo(() => {
if (!columnPreferences) return 0;
return TASK_STATUS_COLUMNS.filter(
(status) => columnPreferences[status]?.isCollapsed
).length;
}, [columnPreferences]);
// Filter tasks based on archive status
const filteredTasks = useMemo(() => {
if (showArchived) {
@@ -932,6 +1109,127 @@ export function KanbanBoard({ tasks, onTaskClick, onNewTaskClick, onRefresh, isR
}
}, [projectId, loadTaskOrder]);
// Load kanban column preferences on mount and when project changes
useEffect(() => {
if (projectId) {
loadKanbanPreferences(projectId);
}
}, [projectId, loadKanbanPreferences]);
// Create a callback to toggle collapsed state and save to storage
const handleToggleColumnCollapsed = useCallback((status: typeof TASK_STATUS_COLUMNS[number]) => {
// Capture projectId at function start to avoid stale closure in setTimeout
const currentProjectId = projectId;
toggleColumnCollapsed(status);
// Save preferences after toggling
if (currentProjectId) {
// Use setTimeout to ensure state is updated before saving
setTimeout(() => {
saveKanbanPreferences(currentProjectId);
}, 0);
}
}, [toggleColumnCollapsed, saveKanbanPreferences, projectId]);
// Create a callback to expand all collapsed columns and save to storage
const handleExpandAll = useCallback(() => {
// Capture projectId at function start to avoid stale closure in setTimeout
const currentProjectId = projectId;
// Expand all collapsed columns
for (const status of TASK_STATUS_COLUMNS) {
if (columnPreferences?.[status]?.isCollapsed) {
setColumnCollapsed(status, false);
}
}
// Save preferences after expanding
if (currentProjectId) {
setTimeout(() => {
saveKanbanPreferences(currentProjectId);
}, 0);
}
}, [columnPreferences, setColumnCollapsed, saveKanbanPreferences, projectId]);
// Create a callback to toggle locked state and save to storage
const handleToggleColumnLocked = useCallback((status: typeof TASK_STATUS_COLUMNS[number]) => {
// Capture projectId at function start to avoid stale closure in setTimeout
const currentProjectId = projectId;
toggleColumnLocked(status);
// Save preferences after toggling
if (currentProjectId) {
// Use setTimeout to ensure state is updated before saving
setTimeout(() => {
saveKanbanPreferences(currentProjectId);
}, 0);
}
}, [toggleColumnLocked, saveKanbanPreferences, projectId]);
// Resize handlers for column width adjustment
const handleResizeStart = useCallback((status: typeof TASK_STATUS_COLUMNS[number], startX: number) => {
const currentWidth = columnPreferences?.[status]?.width ?? DEFAULT_COLUMN_WIDTH;
resizeStartX.current = startX;
resizeStartWidth.current = currentWidth;
// Capture projectId at resize start to ensure we save to the correct project
resizeProjectIdRef.current = projectId ?? null;
setResizingColumn(status);
}, [columnPreferences, projectId]);
const handleResizeMove = useCallback((clientX: number) => {
if (!resizingColumn) return;
const deltaX = clientX - resizeStartX.current;
const newWidth = Math.max(MIN_COLUMN_WIDTH, Math.min(MAX_COLUMN_WIDTH, resizeStartWidth.current + deltaX));
setColumnWidth(resizingColumn, newWidth);
}, [resizingColumn, setColumnWidth]);
const handleResizeEnd = useCallback(() => {
// Use the projectId captured at resize start to avoid saving to wrong project
const savedProjectId = resizeProjectIdRef.current;
if (resizingColumn && savedProjectId) {
saveKanbanPreferences(savedProjectId);
}
setResizingColumn(null);
resizeProjectIdRef.current = null;
}, [resizingColumn, saveKanbanPreferences]);
// Document-level event listeners for resize dragging
useEffect(() => {
if (!resizingColumn) return;
const handleMouseMove = (e: MouseEvent) => {
handleResizeMove(e.clientX);
};
const handleMouseUp = () => {
handleResizeEnd();
};
const handleTouchMove = (e: TouchEvent) => {
if (e.touches.length === 0) return;
handleResizeMove(e.touches[0].clientX);
};
const handleTouchEnd = () => {
handleResizeEnd();
};
// Prevent text selection and set resize cursor during drag
document.body.style.userSelect = 'none';
document.body.style.cursor = 'col-resize';
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
document.addEventListener('touchmove', handleTouchMove, { passive: false });
document.addEventListener('touchend', handleTouchEnd);
return () => {
document.body.style.userSelect = '';
document.body.style.cursor = '';
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
document.removeEventListener('touchmove', handleTouchMove);
document.removeEventListener('touchend', handleTouchEnd);
};
}, [resizingColumn, handleResizeMove, handleResizeEnd]);
// Clean up stale task IDs from order when tasks change (e.g., after deletion)
// This ensures the persisted order doesn't contain IDs for deleted tasks
useEffect(() => {
@@ -1084,19 +1382,37 @@ export function KanbanBoard({ tasks, onTaskClick, onNewTaskClick, onRefresh, isR
return (
<div className="flex h-full flex-col">
{/* Kanban header with refresh button */}
{onRefresh && (
<div className="flex items-center justify-end px-6 pt-4 pb-2">
<Button
variant="ghost"
size="sm"
onClick={onRefresh}
disabled={isRefreshing}
className="gap-2 text-muted-foreground hover:text-foreground"
>
<RefreshCw className={cn("h-4 w-4", isRefreshing && "animate-spin")} />
{isRefreshing ? t('common:buttons.refreshing') : t('tasks:refreshTasks')}
</Button>
{/* Kanban header with refresh button and expand all */}
{(onRefresh || collapsedColumnCount >= 3) && (
<div className="flex items-center justify-between px-6 pt-4 pb-2">
<div className="flex items-center gap-2">
{/* Expand All button - appears when 3+ columns are collapsed */}
{collapsedColumnCount >= 3 && (
<Button
variant="outline"
size="sm"
onClick={handleExpandAll}
className="gap-2 text-muted-foreground hover:text-foreground"
>
<ChevronsRight className="h-4 w-4" />
{t('tasks:kanban.expandAll')}
</Button>
)}
</div>
<div className="flex items-center gap-2">
{onRefresh && (
<Button
variant="ghost"
size="sm"
onClick={onRefresh}
disabled={isRefreshing}
className="gap-2 text-muted-foreground hover:text-foreground"
>
<RefreshCw className={cn("h-4 w-4", isRefreshing && "animate-spin")} />
{isRefreshing ? t('common:buttons.refreshing') : t('tasks:refreshTasks')}
</Button>
)}
</div>
</div>
)}
{/* Kanban columns */}
@@ -1133,6 +1449,14 @@ export function KanbanBoard({ tasks, onTaskClick, onNewTaskClick, onRefresh, isR
onSelectAll={status === 'human_review' ? selectAllTasks : undefined}
onDeselectAll={status === 'human_review' ? deselectAllTasks : undefined}
onToggleSelect={status === 'human_review' ? toggleTaskSelection : undefined}
isCollapsed={columnPreferences?.[status]?.isCollapsed}
onToggleCollapsed={() => handleToggleColumnCollapsed(status)}
columnWidth={columnPreferences?.[status]?.width}
isResizing={resizingColumn === status}
onResizeStart={(startX) => handleResizeStart(status, startX)}
onResizeEnd={handleResizeEnd}
isLocked={columnPreferences?.[status]?.isLocked}
onToggleLocked={() => handleToggleColumnLocked(status)}
/>
))}
</div>
@@ -0,0 +1,312 @@
import { create } from 'zustand';
import type { TaskStatusColumn } from '../../shared/constants/task';
import { TASK_STATUS_COLUMNS } from '../../shared/constants/task';
// ============================================
// Types
// ============================================
/**
* Column preferences for a single kanban column
*/
export interface ColumnPreferences {
/** Column width in pixels (180-600px range) */
width: number;
/** Whether the column is collapsed (narrow vertical strip) */
isCollapsed: boolean;
/** Whether the column width is locked (prevents resize) */
isLocked: boolean;
}
/**
* All column preferences keyed by status column
*/
export type KanbanColumnPreferences = Record<TaskStatusColumn, ColumnPreferences>;
/**
* Kanban settings store state
*/
interface KanbanSettingsState {
/** Column preferences for each status column */
columnPreferences: KanbanColumnPreferences | null;
// Actions
/** Initialize column preferences (call on mount) */
initializePreferences: () => void;
/** Set column width */
setColumnWidth: (column: TaskStatusColumn, width: number) => void;
/** Toggle column collapsed state */
toggleColumnCollapsed: (column: TaskStatusColumn) => void;
/** Set column collapsed state explicitly */
setColumnCollapsed: (column: TaskStatusColumn, isCollapsed: boolean) => void;
/** Toggle column locked state */
toggleColumnLocked: (column: TaskStatusColumn) => void;
/** Set column locked state explicitly */
setColumnLocked: (column: TaskStatusColumn, isLocked: boolean) => void;
/** Load preferences from localStorage */
loadPreferences: (projectId: string) => void;
/** Save preferences to localStorage */
savePreferences: (projectId: string) => boolean;
/** Reset preferences to defaults */
resetPreferences: (projectId: string) => void;
/** Get preferences for a single column */
getColumnPreferences: (column: TaskStatusColumn) => ColumnPreferences;
}
// ============================================
// Constants
// ============================================
/** localStorage key prefix for kanban settings persistence */
const KANBAN_SETTINGS_KEY_PREFIX = 'kanban-column-prefs';
/** Default column width in pixels */
export const DEFAULT_COLUMN_WIDTH = 320;
/** Minimum column width in pixels */
export const MIN_COLUMN_WIDTH = 180;
/** Maximum column width in pixels */
export const MAX_COLUMN_WIDTH = 600;
/** Collapsed column width in pixels */
export const COLLAPSED_COLUMN_WIDTH = 48;
// ============================================
// Helper Functions
// ============================================
/**
* Get the localStorage key for a project's kanban settings
*/
function getKanbanSettingsKey(projectId: string): string {
return `${KANBAN_SETTINGS_KEY_PREFIX}-${projectId}`;
}
/**
* Create default column preferences for all columns
*/
function createDefaultPreferences(): KanbanColumnPreferences {
const preferences: Partial<KanbanColumnPreferences> = {};
for (const column of TASK_STATUS_COLUMNS) {
preferences[column] = {
width: DEFAULT_COLUMN_WIDTH,
isCollapsed: false,
isLocked: false
};
}
return preferences as KanbanColumnPreferences;
}
/**
* Validate column preferences structure
* Returns true if valid, false if invalid/incomplete
*/
function validatePreferences(data: unknown): data is KanbanColumnPreferences {
if (!data || typeof data !== 'object' || Array.isArray(data)) {
return false;
}
const prefs = data as Record<string, unknown>;
// Validate each required column exists with correct structure
for (const column of TASK_STATUS_COLUMNS) {
const columnPrefs = prefs[column];
if (!columnPrefs || typeof columnPrefs !== 'object') {
return false;
}
const cp = columnPrefs as Record<string, unknown>;
// Validate width is a number within bounds
if (typeof cp.width !== 'number' || cp.width < MIN_COLUMN_WIDTH || cp.width > MAX_COLUMN_WIDTH) {
return false;
}
// Validate boolean fields
if (typeof cp.isCollapsed !== 'boolean' || typeof cp.isLocked !== 'boolean') {
return false;
}
}
return true;
}
/**
* Clamp a width value to valid bounds
*/
function clampWidth(width: number): number {
return Math.max(MIN_COLUMN_WIDTH, Math.min(MAX_COLUMN_WIDTH, width));
}
// ============================================
// Store
// ============================================
export const useKanbanSettingsStore = create<KanbanSettingsState>((set, get) => ({
columnPreferences: null,
initializePreferences: () => {
const state = get();
if (!state.columnPreferences) {
set({ columnPreferences: createDefaultPreferences() });
}
},
setColumnWidth: (column, width) => {
set((state) => {
if (!state.columnPreferences) return state;
// Don't allow width changes on locked columns
if (state.columnPreferences[column].isLocked) {
return state;
}
const clampedWidth = clampWidth(width);
return {
columnPreferences: {
...state.columnPreferences,
[column]: {
...state.columnPreferences[column],
width: clampedWidth
}
}
};
});
},
toggleColumnCollapsed: (column) => {
set((state) => {
if (!state.columnPreferences) return state;
return {
columnPreferences: {
...state.columnPreferences,
[column]: {
...state.columnPreferences[column],
isCollapsed: !state.columnPreferences[column].isCollapsed
}
}
};
});
},
setColumnCollapsed: (column, isCollapsed) => {
set((state) => {
if (!state.columnPreferences) return state;
return {
columnPreferences: {
...state.columnPreferences,
[column]: {
...state.columnPreferences[column],
isCollapsed
}
}
};
});
},
toggleColumnLocked: (column) => {
set((state) => {
if (!state.columnPreferences) return state;
return {
columnPreferences: {
...state.columnPreferences,
[column]: {
...state.columnPreferences[column],
isLocked: !state.columnPreferences[column].isLocked
}
}
};
});
},
setColumnLocked: (column, isLocked) => {
set((state) => {
if (!state.columnPreferences) return state;
return {
columnPreferences: {
...state.columnPreferences,
[column]: {
...state.columnPreferences[column],
isLocked
}
}
};
});
},
loadPreferences: (projectId) => {
try {
const key = getKanbanSettingsKey(projectId);
const stored = localStorage.getItem(key);
if (stored) {
const parsed = JSON.parse(stored);
// Validate structure before using
if (validatePreferences(parsed)) {
set({ columnPreferences: parsed });
return;
}
// Invalid data structure, use defaults
console.warn('[KanbanSettingsStore] Invalid preferences in localStorage, using defaults');
}
// No stored preferences or invalid, use defaults
set({ columnPreferences: createDefaultPreferences() });
} catch (error) {
console.error('[KanbanSettingsStore] Failed to load preferences:', error);
set({ columnPreferences: createDefaultPreferences() });
}
},
savePreferences: (projectId) => {
try {
const state = get();
if (!state.columnPreferences) {
return false;
}
const key = getKanbanSettingsKey(projectId);
localStorage.setItem(key, JSON.stringify(state.columnPreferences));
return true;
} catch (error) {
console.error('[KanbanSettingsStore] Failed to save preferences:', error);
return false;
}
},
resetPreferences: (projectId) => {
try {
const key = getKanbanSettingsKey(projectId);
localStorage.removeItem(key);
set({ columnPreferences: createDefaultPreferences() });
} catch (error) {
console.error('[KanbanSettingsStore] Failed to reset preferences:', error);
}
},
getColumnPreferences: (column) => {
const state = get();
if (!state.columnPreferences) {
return {
width: DEFAULT_COLUMN_WIDTH,
isCollapsed: false,
isLocked: false
};
}
return state.columnPreferences[column];
}
}));
@@ -1712,3 +1712,4 @@ body {
[data-ui-scale="190"] { font-size: 30.4px; }
[data-ui-scale="195"] { font-size: 31.2px; }
[data-ui-scale="200"] { font-size: 32px; }
@@ -100,7 +100,14 @@
"selectedCountOne": "{{count}} task selected",
"selectedCountOther": "{{count}} tasks selected",
"createPRs": "Create PRs",
"clearSelection": "Clear Selection"
"clearSelection": "Clear Selection",
"collapseColumn": "Collapse column",
"expandColumn": "Expand column",
"resizeColumn": "Resize column",
"lockColumn": "Lock column width",
"unlockColumn": "Unlock column width",
"columnLocked": "Column width is locked",
"expandAll": "Expand all columns"
},
"queue": {
"limitReached": "Parallel task limit reached ({{current}}/{{max}}). Task moved to queue.",
@@ -99,7 +99,14 @@
"selectedCountOne": "{{count}} tâche sélectionnée",
"selectedCountOther": "{{count}} tâches sélectionnées",
"createPRs": "Créer les PRs",
"clearSelection": "Effacer la sélection"
"clearSelection": "Effacer la sélection",
"collapseColumn": "Réduire la colonne",
"expandColumn": "Développer la colonne",
"resizeColumn": "Redimensionner la colonne",
"lockColumn": "Verrouiller la largeur de la colonne",
"unlockColumn": "Déverrouiller la largeur de la colonne",
"columnLocked": "La largeur de la colonne est verrouillée",
"expandAll": "Développer toutes les colonnes"
},
"queue": {
"limitReached": "Limite de tâches parallèles atteinte ({{current}}/{{max}}). Tâche déplacée vers la file d'attente.",