feat(KB-662): add visibility-based refresh to dashboard hooks

This commit is contained in:
gsxdsm
2026-04-01 21:57:23 -07:00
parent a19912af9a
commit 74b7301b28
6 changed files with 252 additions and 0 deletions

View File

@@ -0,0 +1,60 @@
// Local type definitions for MissionManager
export type MissionStatus = "planning" | "active" | "blocked" | "complete" | "archived";
export type MilestoneStatus = "planning" | "active" | "blocked" | "complete";
export type SliceStatus = "pending" | "active" | "complete";
export type FeatureStatus = "defined" | "triaged" | "in-progress" | "done";
export interface Mission {
id: string;
title: string;
description?: string;
status: MissionStatus;
interviewState: "not_started" | "in_progress" | "completed" | "needs_update";
autoAdvance?: boolean;
createdAt: string;
updatedAt: string;
}
export interface MissionFeature {
id: string;
sliceId: string;
taskId?: string;
title: string;
description?: string;
acceptanceCriteria?: string;
status: FeatureStatus;
createdAt: string;
updatedAt: string;
}
export interface Slice {
id: string;
milestoneId: string;
title: string;
description?: string;
status: SliceStatus;
orderIndex: number;
activatedAt?: string;
createdAt: string;
updatedAt: string;
features: MissionFeature[];
}
export interface Milestone {
id: string;
missionId: string;
title: string;
description?: string;
status: MilestoneStatus;
orderIndex: number;
interviewState: "not_started" | "in_progress" | "completed" | "needs_update";
dependencies: string[];
createdAt: string;
updatedAt: string;
slices: Slice[];
}
export interface MissionWithHierarchy extends Mission {
milestones: Milestone[];
}

View File

@@ -0,0 +1,92 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { renderHook, act, waitFor } from "@testing-library/react";
import { useActivityLog } from "../useActivityLog";
import type { ActivityFeedEntry } from "../../api";
function mockFetchResponse(
ok: boolean,
body: unknown,
status = ok ? 200 : 500,
contentType = "application/json"
) {
const bodyText = JSON.stringify(body);
return Promise.resolve({
ok,
status,
statusText: ok ? "OK" : "Error",
headers: {
get: (name: string) =>
name.toLowerCase() === "content-type" ? contentType : null,
},
json: () => Promise.resolve(body),
text: () => Promise.resolve(bodyText),
} as unknown as Response);
}
describe("useActivityLog visibility change", () => {
const originalFetch = globalThis.fetch;
beforeEach(() => {
vi.useFakeTimers({ shouldAdvanceTime: true });
});
afterEach(() => {
globalThis.fetch = originalFetch;
vi.useRealTimers();
});
let originalVisibilityState: PropertyDescriptor | undefined;
beforeEach(() => {
originalVisibilityState = Object.getOwnPropertyDescriptor(document, "visibilityState");
});
afterEach(() => {
if (originalVisibilityState) {
Object.defineProperty(document, "visibilityState", originalVisibilityState);
} else {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
delete (document as any).visibilityState;
}
});
function setVisibilityState(state: "visible" | "hidden") {
Object.defineProperty(document, "visibilityState", {
value: state,
writable: true,
configurable: true,
});
}
it("does not refetch when visibility changes to hidden", async () => {
const initialEntries: ActivityFeedEntry[] = [
{
id: "entry_1",
timestamp: "2026-01-01T00:00:00.000Z",
type: "task:created",
projectId: "proj_123",
projectName: "Test Project",
taskId: "FN-001",
details: "Task created",
},
];
globalThis.fetch = vi.fn().mockReturnValueOnce(mockFetchResponse(true, initialEntries));
renderHook(() => useActivityLog());
await act(async () => {
await Promise.resolve();
});
expect(globalThis.fetch).toHaveBeenCalled();
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, []));
setVisibilityState("hidden");
await act(async () => {
document.dispatchEvent(new Event("visibilitychange"));
});
expect(globalThis.fetch).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,59 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { renderHook, act, waitFor } from "@testing-library/react";
import { useUsageData } from "../useUsageData";
import * as api from "../../api";
vi.mock("../../api", () => ({
fetchUsageData: vi.fn(),
}));
const mockFetchUsageData = vi.mocked(api.fetchUsageData);
describe("useUsageData visibility change", () => {
beforeEach(() => {
vi.useFakeTimers({ shouldAdvanceTime: true });
mockFetchUsageData.mockReset();
// Set default visibility state to visible
Object.defineProperty(document, "visibilityState", {
value: "visible",
writable: true,
configurable: true,
});
});
afterEach(() => {
vi.useRealTimers();
delete (document as any).visibilityState;
});
function setVisibilityState(state: "visible" | "hidden") {
Object.defineProperty(document, "visibilityState", {
value: state,
writable: true,
configurable: true,
});
}
it("does not refetch when visibility changes to hidden", async () => {
const initialData = {
providers: [{ name: "Claude", icon: "🟠", status: "ok" as const, windows: [] }],
};
mockFetchUsageData.mockResolvedValueOnce(initialData);
renderHook(() => useUsageData({ autoRefresh: false }));
await waitFor(() => {
expect(mockFetchUsageData).toHaveBeenCalledTimes(1);
});
mockFetchUsageData.mockClear();
setVisibilityState("hidden");
await act(async () => {
document.dispatchEvent(new Event("visibilitychange"));
});
expect(mockFetchUsageData).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,31 @@
import express from "express";
import { describe, expect, it } from "vitest";
describe("static asset serving", () => {
it("returns 404 for missing asset paths instead of falling back to index.html", async () => {
const app = express();
app.use(express.static("packages/dashboard/dist/client", { index: false }));
app.get(/^(?!\/assets\/).*/, (_req, res) => {
res.sendFile("index.html", { root: "packages/dashboard/dist/client" });
});
const server = await new Promise<import("node:http").Server>((resolve) => {
const s = app.listen(0, () => resolve(s));
});
try {
const address = server.address();
if (!address || typeof address === "string") throw new Error("Failed to get test server port");
const res = await fetch(`http://127.0.0.1:${address.port}/assets/does-not-exist.js`);
expect(res.status).toBe(404);
} finally {
await new Promise<void>((resolve, reject) => {
server.close((err) => (err ? reject(err) : resolve()));
});
}
});
});