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:
Dustin Byrne
2026-03-25 23:31:16 -04:00
parent ae2f9cfaea
commit 133fc59297
5 changed files with 201 additions and 1 deletions

View File

@@ -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

View 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");
});
});

View 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();
});
});

View 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;
}

View File

@@ -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);