feat(FN-1005): add mission title caching, abbreviation, and display on TaskCard

- Add mission title abbreviation utility (truncate long titles with ellipsis)
- Add mission title caching to avoid repeated lookups per render
- Display abbreviated mission title as a badge on TaskCard component
- Add comprehensive tests for mission title display, caching, and edge cases
This commit is contained in:
gsxdsm
2026-04-05 19:19:19 -07:00
parent 35d1326b82
commit f8be9765ba
2 changed files with 203 additions and 4 deletions

View File

@@ -20,9 +20,10 @@ vi.mock("lucide-react", () => ({
vi.mock("../api", () => ({
fetchTaskDetail: vi.fn(),
uploadAttachment: vi.fn(),
fetchMission: vi.fn(),
}));
import { uploadAttachment } from "../api";
import { uploadAttachment, fetchMission } from "../api";
function makeTask(overrides: Partial<Task> = {}): Task {
return {
@@ -257,3 +258,148 @@ describe("TaskCard", () => {
expect(actionsContainer?.contains(archiveBtn)).toBe(true);
});
});
describe("TaskCard mission badge", () => {
// Access the internal cache reset helper
let clearCache: () => void;
beforeAll(async () => {
const mod = await import("./TaskCard");
clearCache = (mod as any).__test_clearMissionTitleCache;
});
beforeEach(() => {
clearCache?.();
vi.mocked(fetchMission).mockReset();
});
it("displays mission title instead of missionId", async () => {
vi.mocked(fetchMission).mockResolvedValue({
id: "M-ABC123",
title: "Database Optimization",
status: "active",
interviewState: "completed",
milestones: [],
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
});
const { container } = render(
<TaskCard
task={makeTask({ missionId: "M-ABC123" })}
onOpenDetail={noop}
addToast={noop}
/>,
);
const badge = container.querySelector(".card-mission-badge");
expect(badge).not.toBeNull();
await waitFor(() => {
expect(badge?.textContent).toContain("Database Optimiza...");
});
});
it("abbreviates long mission titles with ellipsis", async () => {
vi.mocked(fetchMission).mockResolvedValue({
id: "M-LONG1",
title: "This Is A Very Long Mission Title That Exceeds Twenty Characters",
status: "active",
interviewState: "completed",
milestones: [],
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
});
const { container } = render(
<TaskCard
task={makeTask({ missionId: "M-LONG1" })}
onOpenDetail={noop}
addToast={noop}
/>,
);
const badge = container.querySelector(".card-mission-badge");
expect(badge).not.toBeNull();
await waitFor(() => {
// MAX_MISSION_TITLE_LENGTH is 20, so first 17 chars + "..."
expect(badge?.textContent).toContain("This Is A Very Lo...");
});
});
it("falls back to missionId on fetch error", async () => {
vi.mocked(fetchMission).mockRejectedValue(new Error("Network error"));
const { container } = render(
<TaskCard
task={makeTask({ missionId: "M-ERR99" })}
onOpenDetail={noop}
addToast={noop}
/>,
);
const badge = container.querySelector(".card-mission-badge");
expect(badge).not.toBeNull();
await waitFor(() => {
expect(badge?.textContent).toContain("M-ERR99");
});
});
it("shows mission title in title attribute", async () => {
vi.mocked(fetchMission).mockResolvedValue({
id: "M-TITLE",
title: "Refactor Auth",
status: "active",
interviewState: "completed",
milestones: [],
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
});
const { container } = render(
<TaskCard
task={makeTask({ missionId: "M-TITLE" })}
onOpenDetail={noop}
addToast={noop}
/>,
);
const badge = container.querySelector(".card-mission-badge");
expect(badge).not.toBeNull();
await waitFor(() => {
expect(badge?.getAttribute("title")).toBe("Mission: Refactor Auth");
});
});
it("shows short mission title without abbreviation", async () => {
vi.mocked(fetchMission).mockResolvedValue({
id: "M-SHORT",
title: "Auth Fix",
status: "active",
interviewState: "completed",
milestones: [],
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
});
const { container } = render(
<TaskCard
task={makeTask({ missionId: "M-SHORT" })}
onOpenDetail={noop}
addToast={noop}
/>,
);
const badge = container.querySelector(".card-mission-badge");
expect(badge).not.toBeNull();
await waitFor(() => {
// "Auth Fix" is 8 chars, well under 20 — no abbreviation needed
expect(badge?.textContent).toContain("Auth Fix");
expect(badge?.textContent).not.toContain("...");
});
});
});

View File

@@ -1,7 +1,7 @@
import { memo, useCallback, useState, useRef, useEffect, useMemo } from "react";
import { Link, Clock, Layers, Pencil, ChevronDown, Folder, Target } from "lucide-react";
import type { Task, TaskDetail, Column, PrInfo, IssueInfo } from "@fusion/core";
import { fetchTaskDetail, uploadAttachment } from "../api";
import { fetchTaskDetail, uploadAttachment, fetchMission } from "../api";
import { GitHubBadge } from "./GitHubBadge";
import { pickPreferredBadge } from "./TaskCardBadge";
import { useBadgeWebSocket } from "../hooks/useBadgeWebSocket";
@@ -11,6 +11,37 @@ import { useTaskDiffStats } from "../hooks/useTaskDiffStats";
import { isTaskStuck } from "../utils/taskStuck";
import type { ToastType } from "../hooks/useToast";
// ── Mission title caching ───────────────────────────────────────────────────
const missionTitleCache = new Map<string, string>();
/** @internal Test helper to reset the mission title cache between tests */
export function __test_clearMissionTitleCache(): void {
missionTitleCache.clear();
}
async function getMissionTitle(missionId: string, projectId?: string): Promise<string> {
const cached = missionTitleCache.get(missionId);
if (cached) return cached;
try {
const mission = await fetchMission(missionId, projectId);
missionTitleCache.set(missionId, mission.title);
return mission.title;
} catch {
return missionId;
}
}
const MAX_MISSION_TITLE_LENGTH = 20;
function abbreviateMissionTitle(title: string): string {
if (title.length <= MAX_MISSION_TITLE_LENGTH) return title;
return title.slice(0, MAX_MISSION_TITLE_LENGTH - 3) + "...";
}
// ── Constants ───────────────────────────────────────────────────────────────
const COLUMN_COLOR_MAP: Record<Column, string> = {
triage: "rgba(210,153,34,0.15)",
todo: "rgba(88,166,255,0.15)",
@@ -151,6 +182,7 @@ function TaskCardComponent({
task.column === "in-progress" ||
(task.column === "triage" && task.steps.some(s => s.status === "done" || s.status === "skipped"))
);
const [missionTitle, setMissionTitle] = useState<string | null>(null);
const descTextareaRef = useRef<HTMLTextAreaElement>(null);
const touchOpenHandledRef = useRef(false);
@@ -172,6 +204,27 @@ function TaskCardComponent({
setEditDescription(task.description || "");
}, [task.id, task.description]);
// Fetch mission title when missionId is set
useEffect(() => {
if (!task.missionId) {
setMissionTitle(null);
return;
}
// Check cache synchronously first
const cached = missionTitleCache.get(task.missionId);
if (cached) {
setMissionTitle(cached);
return;
}
let cancelled = false;
void getMissionTitle(task.missionId, projectId).then((title) => {
if (!cancelled) setMissionTitle(title);
});
return () => { cancelled = true; };
}, [task.missionId, projectId]);
// Auto-focus and auto-resize description textarea when entering edit mode
useEffect(() => {
if (isEditing && descTextareaRef.current) {
@@ -616,12 +669,12 @@ function TaskCardComponent({
<span
className="card-mission-badge"
onClick={handleMissionClick}
title={`Mission: ${task.missionId}`}
title={`Mission: ${missionTitle ?? task.missionId}`}
role={onOpenMission ? "button" : undefined}
tabIndex={onOpenMission ? 0 : undefined}
>
<Target size={11} />
{task.missionId}
{abbreviateMissionTitle(missionTitle ?? task.missionId)}
</span>
)}
<div className="card-header-actions">