feat(HAI-051): add column flash animation on count increase
- Create useFlashOnIncrease hook to detect count increases - Wire useFlashOnIncrease hook into Column component - Add CSS flash animation keyframes and styles - Add unit tests for useFlashOnIncrease hook - Add Column component flash behavior tests
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import { useState, useCallback } from "react";
|
||||
import { useFlashOnIncrease } from "../hooks/useFlashOnIncrease";
|
||||
import type { Task, TaskDetail, TaskCreateInput, Column as ColumnType } from "@hai/core";
|
||||
import { COLUMN_LABELS, COLUMN_DESCRIPTIONS } from "@hai/core";
|
||||
import { TaskCard } from "./TaskCard";
|
||||
@@ -25,6 +26,7 @@ interface ColumnProps {
|
||||
|
||||
export function Column({ column, tasks, allTasks, maxConcurrent, onMoveTask, onOpenDetail, addToast, isCreating, onCancelCreate, onCreateTask, onNewTask, autoMerge, onToggleAutoMerge }: ColumnProps) {
|
||||
const [dragOver, setDragOver] = useState(false);
|
||||
const countFlashing = useFlashOnIncrease(tasks.length);
|
||||
|
||||
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
@@ -63,7 +65,7 @@ export function Column({ column, tasks, allTasks, maxConcurrent, onMoveTask, onO
|
||||
<div className="column-header">
|
||||
<div className={`column-dot dot-${column}`} />
|
||||
<h2>{COLUMN_LABELS[column]}</h2>
|
||||
<span className="column-count">{tasks.length}</span>
|
||||
<span className={`column-count${countFlashing ? " count-flash" : ""}`}>{tasks.length}</span>
|
||||
{column === "in-review" && onToggleAutoMerge && (
|
||||
<label className="auto-merge-toggle" title={autoMerge ? "Auto-merge enabled" : "Auto-merge disabled"}>
|
||||
<input
|
||||
|
||||
75
packages/dashboard/app/components/__tests__/Column.test.tsx
Normal file
75
packages/dashboard/app/components/__tests__/Column.test.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { Column } from "../Column";
|
||||
import type { Task, Column as ColumnType } from "@hai/core";
|
||||
|
||||
// Mock child components to keep tests focused on the Column badge behavior
|
||||
vi.mock("../TaskCard", () => ({
|
||||
TaskCard: ({ task }: { task: Task }) => <div data-testid={`task-${task.id}`} />,
|
||||
}));
|
||||
vi.mock("../WorktreeGroup", () => ({
|
||||
WorktreeGroup: () => <div />,
|
||||
}));
|
||||
vi.mock("../InlineCreateCard", () => ({
|
||||
InlineCreateCard: () => <div />,
|
||||
}));
|
||||
vi.mock("lucide-react", () => ({
|
||||
Link: () => null,
|
||||
Clock: () => null,
|
||||
}));
|
||||
|
||||
function makeTask(id: string): Task {
|
||||
return {
|
||||
id,
|
||||
title: `Task ${id}`,
|
||||
column: "triage" as ColumnType,
|
||||
status: undefined as any,
|
||||
steps: [],
|
||||
dependencies: [],
|
||||
description: "",
|
||||
created: new Date().toISOString(),
|
||||
updated: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
const defaultProps = {
|
||||
column: "triage" as ColumnType,
|
||||
allTasks: [] as Task[],
|
||||
maxConcurrent: 2,
|
||||
onMoveTask: vi.fn().mockResolvedValue({} as Task),
|
||||
onOpenDetail: vi.fn(),
|
||||
addToast: vi.fn(),
|
||||
};
|
||||
|
||||
describe("Column count-flash", () => {
|
||||
it("does not apply count-flash class on initial render", () => {
|
||||
const tasks = [makeTask("HAI-001")];
|
||||
render(<Column {...defaultProps} tasks={tasks} />);
|
||||
|
||||
const badge = screen.getByText("1");
|
||||
expect(badge.className).toContain("column-count");
|
||||
expect(badge.className).not.toContain("count-flash");
|
||||
});
|
||||
|
||||
it("applies count-flash class when task count increases", () => {
|
||||
const tasks = [makeTask("HAI-001")];
|
||||
const { rerender } = render(<Column {...defaultProps} tasks={tasks} />);
|
||||
|
||||
const moreTasks = [makeTask("HAI-001"), makeTask("HAI-002")];
|
||||
rerender(<Column {...defaultProps} tasks={moreTasks} />);
|
||||
|
||||
const badge = screen.getByText("2");
|
||||
expect(badge.className).toContain("count-flash");
|
||||
});
|
||||
|
||||
it("does not apply count-flash class when task count decreases", () => {
|
||||
const tasks = [makeTask("HAI-001"), makeTask("HAI-002")];
|
||||
const { rerender } = render(<Column {...defaultProps} tasks={tasks} />);
|
||||
|
||||
const fewerTasks = [makeTask("HAI-001")];
|
||||
rerender(<Column {...defaultProps} tasks={fewerTasks} />);
|
||||
|
||||
const badge = screen.getByText("1");
|
||||
expect(badge.className).not.toContain("count-flash");
|
||||
});
|
||||
});
|
||||
78
packages/dashboard/app/hooks/useFlashOnIncrease.test.ts
Normal file
78
packages/dashboard/app/hooks/useFlashOnIncrease.test.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { renderHook, act } from "@testing-library/react";
|
||||
import { useFlashOnIncrease } from "./useFlashOnIncrease";
|
||||
|
||||
describe("useFlashOnIncrease", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("does not flash on initial render", () => {
|
||||
const { result } = renderHook(() => useFlashOnIncrease(3));
|
||||
expect(result.current).toBe(false);
|
||||
});
|
||||
|
||||
it("flashes when count increases", () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ count }) => useFlashOnIncrease(count),
|
||||
{ initialProps: { count: 3 } },
|
||||
);
|
||||
|
||||
rerender({ count: 5 });
|
||||
expect(result.current).toBe(true);
|
||||
});
|
||||
|
||||
it("resets flashing after duration", () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ count }) => useFlashOnIncrease(count, 700),
|
||||
{ initialProps: { count: 3 } },
|
||||
);
|
||||
|
||||
rerender({ count: 5 });
|
||||
expect(result.current).toBe(true);
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(700);
|
||||
});
|
||||
expect(result.current).toBe(false);
|
||||
});
|
||||
|
||||
it("does not flash when count decreases", () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ count }) => useFlashOnIncrease(count),
|
||||
{ initialProps: { count: 5 } },
|
||||
);
|
||||
|
||||
rerender({ count: 2 });
|
||||
expect(result.current).toBe(false);
|
||||
});
|
||||
|
||||
it("does not flash when count stays the same", () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ count }) => useFlashOnIncrease(count),
|
||||
{ initialProps: { count: 5 } },
|
||||
);
|
||||
|
||||
rerender({ count: 5 });
|
||||
expect(result.current).toBe(false);
|
||||
});
|
||||
|
||||
it("cleans up timer on unmount", () => {
|
||||
const clearTimeoutSpy = vi.spyOn(global, "clearTimeout");
|
||||
|
||||
const { rerender, unmount } = renderHook(
|
||||
({ count }) => useFlashOnIncrease(count),
|
||||
{ initialProps: { count: 3 } },
|
||||
);
|
||||
|
||||
rerender({ count: 5 });
|
||||
unmount();
|
||||
|
||||
expect(clearTimeoutSpy).toHaveBeenCalled();
|
||||
clearTimeoutSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
32
packages/dashboard/app/hooks/useFlashOnIncrease.ts
Normal file
32
packages/dashboard/app/hooks/useFlashOnIncrease.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { useRef, useState, useEffect } from "react";
|
||||
|
||||
/**
|
||||
* Returns `true` briefly when `count` increases compared to its previous value.
|
||||
* Used to trigger a CSS flash animation on the column count badge.
|
||||
*
|
||||
* - Does NOT flash on initial render (only on subsequent increases).
|
||||
* - Does NOT flash when count decreases or stays the same.
|
||||
* - Resets after `duration` ms (default 700ms).
|
||||
* - Cleans up timers on unmount.
|
||||
*/
|
||||
export function useFlashOnIncrease(count: number, duration = 700): boolean {
|
||||
const prevRef = useRef<number | null>(null);
|
||||
const [flashing, setFlashing] = useState(false);
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (prevRef.current !== null && count > prevRef.current) {
|
||||
setFlashing(true);
|
||||
timerRef.current = setTimeout(() => setFlashing(false), duration);
|
||||
}
|
||||
prevRef.current = count;
|
||||
|
||||
return () => {
|
||||
if (timerRef.current !== null) {
|
||||
clearTimeout(timerRef.current);
|
||||
}
|
||||
};
|
||||
}, [count, duration]);
|
||||
|
||||
return flashing;
|
||||
}
|
||||
@@ -149,6 +149,19 @@ html, body {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@keyframes count-flash-bg {
|
||||
from {
|
||||
background: #238636;
|
||||
}
|
||||
to {
|
||||
background: var(--card);
|
||||
}
|
||||
}
|
||||
|
||||
.column-count.count-flash {
|
||||
animation: count-flash-bg 700ms ease-out;
|
||||
}
|
||||
|
||||
.column-desc {
|
||||
font-size: 11px;
|
||||
color: var(--text-dim);
|
||||
|
||||
Reference in New Issue
Block a user