Story 31: View Upcoming Stories
Add GET /workflow/upcoming endpoint that reads .story_kit/stories/upcoming/ and returns story IDs with names parsed from frontmatter. Add UpcomingPanel component wired into Chat view with loading, error, empty, and list states. 12 new tests (3 backend, 9 frontend) all passing. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
name: View Upcoming Stories
|
name: View Upcoming Stories
|
||||||
test_plan: pending
|
test_plan: approved
|
||||||
---
|
---
|
||||||
|
|
||||||
# Story 31: View Upcoming Stories
|
# Story 31: View Upcoming Stories
|
||||||
@@ -40,6 +40,7 @@ vi.mock("./api/workflow", () => {
|
|||||||
missing_categories: [],
|
missing_categories: [],
|
||||||
}),
|
}),
|
||||||
getReviewQueueAll: vi.fn().mockResolvedValue({ stories: [] }),
|
getReviewQueueAll: vi.fn().mockResolvedValue({ stories: [] }),
|
||||||
|
getUpcomingStories: vi.fn().mockResolvedValue({ stories: [] }),
|
||||||
recordTests: vi.fn(),
|
recordTests: vi.fn(),
|
||||||
ensureAcceptance: vi.fn(),
|
ensureAcceptance: vi.fn(),
|
||||||
getReviewQueue: vi.fn(),
|
getReviewQueue: vi.fn(),
|
||||||
|
|||||||
@@ -98,6 +98,28 @@ describe("workflowApi", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("getUpcomingStories", () => {
|
||||||
|
it("sends GET to /workflow/upcoming", async () => {
|
||||||
|
const response = {
|
||||||
|
stories: [
|
||||||
|
{ story_id: "31_view_upcoming", name: "View Upcoming" },
|
||||||
|
{ story_id: "32_worktree", name: null },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
mockFetch.mockResolvedValueOnce(okResponse(response));
|
||||||
|
|
||||||
|
const result = await workflowApi.getUpcomingStories();
|
||||||
|
|
||||||
|
expect(mockFetch).toHaveBeenCalledWith(
|
||||||
|
"/api/workflow/upcoming",
|
||||||
|
expect.objectContaining({}),
|
||||||
|
);
|
||||||
|
expect(result.stories).toHaveLength(2);
|
||||||
|
expect(result.stories[0].name).toBe("View Upcoming");
|
||||||
|
expect(result.stories[1].name).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("getReviewQueue", () => {
|
describe("getReviewQueue", () => {
|
||||||
it("sends GET to /workflow/review", async () => {
|
it("sends GET to /workflow/review", async () => {
|
||||||
mockFetch.mockResolvedValueOnce(
|
mockFetch.mockResolvedValueOnce(
|
||||||
|
|||||||
@@ -62,6 +62,15 @@ export interface ReviewListResponse {
|
|||||||
stories: ReviewStory[];
|
stories: ReviewStory[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface UpcomingStory {
|
||||||
|
story_id: string;
|
||||||
|
name: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpcomingStoriesResponse {
|
||||||
|
stories: UpcomingStory[];
|
||||||
|
}
|
||||||
|
|
||||||
const DEFAULT_API_BASE = "/api";
|
const DEFAULT_API_BASE = "/api";
|
||||||
|
|
||||||
function buildApiUrl(path: string, baseUrl = DEFAULT_API_BASE): string {
|
function buildApiUrl(path: string, baseUrl = DEFAULT_API_BASE): string {
|
||||||
@@ -124,6 +133,13 @@ export const workflowApi = {
|
|||||||
getReviewQueueAll(baseUrl?: string) {
|
getReviewQueueAll(baseUrl?: string) {
|
||||||
return requestJson<ReviewListResponse>("/workflow/review/all", {}, baseUrl);
|
return requestJson<ReviewListResponse>("/workflow/review/all", {}, baseUrl);
|
||||||
},
|
},
|
||||||
|
getUpcomingStories(baseUrl?: string) {
|
||||||
|
return requestJson<UpcomingStoriesResponse>(
|
||||||
|
"/workflow/upcoming",
|
||||||
|
{},
|
||||||
|
baseUrl,
|
||||||
|
);
|
||||||
|
},
|
||||||
ensureAcceptance(payload: AcceptanceRequest, baseUrl?: string) {
|
ensureAcceptance(payload: AcceptanceRequest, baseUrl?: string) {
|
||||||
return requestJson<boolean>(
|
return requestJson<boolean>(
|
||||||
"/workflow/acceptance/ensure",
|
"/workflow/acceptance/ensure",
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ vi.mock("../api/workflow", () => {
|
|||||||
ensureAcceptance: vi.fn(),
|
ensureAcceptance: vi.fn(),
|
||||||
recordCoverage: vi.fn(),
|
recordCoverage: vi.fn(),
|
||||||
collectCoverage: vi.fn(),
|
collectCoverage: vi.fn(),
|
||||||
|
getUpcomingStories: vi.fn(),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -54,6 +55,7 @@ const mockedWorkflow = {
|
|||||||
getReviewQueue: vi.mocked(workflowApi.getReviewQueue),
|
getReviewQueue: vi.mocked(workflowApi.getReviewQueue),
|
||||||
getReviewQueueAll: vi.mocked(workflowApi.getReviewQueueAll),
|
getReviewQueueAll: vi.mocked(workflowApi.getReviewQueueAll),
|
||||||
ensureAcceptance: vi.mocked(workflowApi.ensureAcceptance),
|
ensureAcceptance: vi.mocked(workflowApi.ensureAcceptance),
|
||||||
|
getUpcomingStories: vi.mocked(workflowApi.getUpcomingStories),
|
||||||
};
|
};
|
||||||
|
|
||||||
describe("Chat review panel", () => {
|
describe("Chat review panel", () => {
|
||||||
@@ -75,6 +77,7 @@ describe("Chat review panel", () => {
|
|||||||
});
|
});
|
||||||
mockedWorkflow.getReviewQueueAll.mockResolvedValue({ stories: [] });
|
mockedWorkflow.getReviewQueueAll.mockResolvedValue({ stories: [] });
|
||||||
mockedWorkflow.ensureAcceptance.mockResolvedValue(true);
|
mockedWorkflow.ensureAcceptance.mockResolvedValue(true);
|
||||||
|
mockedWorkflow.getUpcomingStories.mockResolvedValue({ stories: [] });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows an empty review queue state", async () => {
|
it("shows an empty review queue state", async () => {
|
||||||
@@ -466,6 +469,23 @@ describe("Chat review panel", () => {
|
|||||||
expect(await screen.findByText(/Coverage: 92\.0%/)).toBeInTheDocument();
|
expect(await screen.findByText(/Coverage: 92\.0%/)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("fetches upcoming stories on mount and renders panel", async () => {
|
||||||
|
mockedWorkflow.getUpcomingStories.mockResolvedValueOnce({
|
||||||
|
stories: [
|
||||||
|
{ story_id: "31_view_upcoming", name: "View Upcoming Stories" },
|
||||||
|
{ story_id: "32_worktree", name: null },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
|
||||||
|
|
||||||
|
expect(await screen.findByText("Upcoming Stories")).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
await screen.findByText("View Upcoming Stories"),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(await screen.findByText("32_worktree")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
it("collect coverage button triggers collection and refreshes gate", async () => {
|
it("collect coverage button triggers collection and refreshes gate", async () => {
|
||||||
const mockedCollectCoverage = vi.mocked(workflowApi.collectCoverage);
|
const mockedCollectCoverage = vi.mocked(workflowApi.collectCoverage);
|
||||||
mockedCollectCoverage.mockResolvedValueOnce({
|
mockedCollectCoverage.mockResolvedValueOnce({
|
||||||
|
|||||||
@@ -3,12 +3,13 @@ import Markdown from "react-markdown";
|
|||||||
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
|
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
|
||||||
import { oneDark } from "react-syntax-highlighter/dist/esm/styles/prism";
|
import { oneDark } from "react-syntax-highlighter/dist/esm/styles/prism";
|
||||||
import { api, ChatWebSocket } from "../api/client";
|
import { api, ChatWebSocket } from "../api/client";
|
||||||
import type { ReviewStory } from "../api/workflow";
|
import type { ReviewStory, UpcomingStory } from "../api/workflow";
|
||||||
import { workflowApi } from "../api/workflow";
|
import { workflowApi } from "../api/workflow";
|
||||||
import type { Message, ProviderConfig, ToolCall } from "../types";
|
import type { Message, ProviderConfig, ToolCall } from "../types";
|
||||||
import { ChatHeader } from "./ChatHeader";
|
import { ChatHeader } from "./ChatHeader";
|
||||||
import { GatePanel } from "./GatePanel";
|
import { GatePanel } from "./GatePanel";
|
||||||
import { ReviewPanel } from "./ReviewPanel";
|
import { ReviewPanel } from "./ReviewPanel";
|
||||||
|
import { UpcomingPanel } from "./UpcomingPanel";
|
||||||
|
|
||||||
const { useCallback, useEffect, useRef, useState } = React;
|
const { useCallback, useEffect, useRef, useState } = React;
|
||||||
|
|
||||||
@@ -61,6 +62,12 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) {
|
|||||||
const [lastGateRefresh, setLastGateRefresh] = useState<Date | null>(null);
|
const [lastGateRefresh, setLastGateRefresh] = useState<Date | null>(null);
|
||||||
const [isCollectingCoverage, setIsCollectingCoverage] = useState(false);
|
const [isCollectingCoverage, setIsCollectingCoverage] = useState(false);
|
||||||
const [coverageError, setCoverageError] = useState<string | null>(null);
|
const [coverageError, setCoverageError] = useState<string | null>(null);
|
||||||
|
const [upcomingStories, setUpcomingStories] = useState<UpcomingStory[]>([]);
|
||||||
|
const [upcomingError, setUpcomingError] = useState<string | null>(null);
|
||||||
|
const [isUpcomingLoading, setIsUpcomingLoading] = useState(false);
|
||||||
|
const [lastUpcomingRefresh, setLastUpcomingRefresh] = useState<Date | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
const storyId = "26_establish_tdd_workflow_and_gates";
|
const storyId = "26_establish_tdd_workflow_and_gates";
|
||||||
const gateStatusColor = isGateLoading
|
const gateStatusColor = isGateLoading
|
||||||
@@ -306,6 +313,58 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true;
|
||||||
|
setIsUpcomingLoading(true);
|
||||||
|
setUpcomingError(null);
|
||||||
|
|
||||||
|
workflowApi
|
||||||
|
.getUpcomingStories()
|
||||||
|
.then((response) => {
|
||||||
|
if (!active) return;
|
||||||
|
setUpcomingStories(response.stories);
|
||||||
|
setLastUpcomingRefresh(new Date());
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
if (!active) return;
|
||||||
|
const message =
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: "Failed to load upcoming stories.";
|
||||||
|
setUpcomingError(message);
|
||||||
|
setUpcomingStories([]);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (active) {
|
||||||
|
setIsUpcomingLoading(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
active = false;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const refreshUpcomingStories = async () => {
|
||||||
|
setIsUpcomingLoading(true);
|
||||||
|
setUpcomingError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await workflowApi.getUpcomingStories();
|
||||||
|
setUpcomingStories(response.stories);
|
||||||
|
setLastUpcomingRefresh(new Date());
|
||||||
|
} catch (error) {
|
||||||
|
const message =
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: "Failed to load upcoming stories.";
|
||||||
|
setUpcomingError(message);
|
||||||
|
setUpcomingStories([]);
|
||||||
|
} finally {
|
||||||
|
setIsUpcomingLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const refreshReviewQueue = async () => {
|
const refreshReviewQueue = async () => {
|
||||||
setIsReviewLoading(true);
|
setIsReviewLoading(true);
|
||||||
setReviewError(null);
|
setReviewError(null);
|
||||||
@@ -593,6 +652,14 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) {
|
|||||||
onCollectCoverage={handleCollectCoverage}
|
onCollectCoverage={handleCollectCoverage}
|
||||||
isCollectingCoverage={isCollectingCoverage}
|
isCollectingCoverage={isCollectingCoverage}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<UpcomingPanel
|
||||||
|
stories={upcomingStories}
|
||||||
|
isLoading={isUpcomingLoading}
|
||||||
|
error={upcomingError}
|
||||||
|
lastRefresh={lastUpcomingRefresh}
|
||||||
|
onRefresh={refreshUpcomingStories}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -9,8 +9,11 @@ const baseProps = {
|
|||||||
gateStatusColor: "#aaa",
|
gateStatusColor: "#aaa",
|
||||||
isGateLoading: false,
|
isGateLoading: false,
|
||||||
gateError: null,
|
gateError: null,
|
||||||
|
coverageError: null,
|
||||||
lastGateRefresh: null,
|
lastGateRefresh: null,
|
||||||
onRefresh: vi.fn(),
|
onRefresh: vi.fn(),
|
||||||
|
onCollectCoverage: vi.fn(),
|
||||||
|
isCollectingCoverage: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
describe("GatePanel", () => {
|
describe("GatePanel", () => {
|
||||||
@@ -21,9 +24,7 @@ describe("GatePanel", () => {
|
|||||||
|
|
||||||
it("shows loading message when isGateLoading is true", () => {
|
it("shows loading message when isGateLoading is true", () => {
|
||||||
render(<GatePanel {...baseProps} isGateLoading={true} />);
|
render(<GatePanel {...baseProps} isGateLoading={true} />);
|
||||||
expect(
|
expect(screen.getByText("Loading workflow gates...")).toBeInTheDocument();
|
||||||
screen.getByText("Loading workflow gates..."),
|
|
||||||
).toBeInTheDocument();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows error with retry button", async () => {
|
it("shows error with retry button", async () => {
|
||||||
@@ -64,13 +65,12 @@ describe("GatePanel", () => {
|
|||||||
warning: null,
|
warning: null,
|
||||||
summary: { total: 5, passed: 5, failed: 0 },
|
summary: { total: 5, passed: 5, failed: 0 },
|
||||||
missingCategories: [],
|
missingCategories: [],
|
||||||
|
coverageReport: null,
|
||||||
}}
|
}}
|
||||||
gateStatusLabel="Ready to accept"
|
gateStatusLabel="Ready to accept"
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
expect(
|
expect(screen.getByText(/5\/5 passing, 0 failing/)).toBeInTheDocument();
|
||||||
screen.getByText(/5\/5 passing, 0 failing/),
|
|
||||||
).toBeInTheDocument();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows missing categories", () => {
|
it("shows missing categories", () => {
|
||||||
@@ -83,12 +83,11 @@ describe("GatePanel", () => {
|
|||||||
warning: null,
|
warning: null,
|
||||||
summary: { total: 0, passed: 0, failed: 0 },
|
summary: { total: 0, passed: 0, failed: 0 },
|
||||||
missingCategories: ["unit", "integration"],
|
missingCategories: ["unit", "integration"],
|
||||||
|
coverageReport: null,
|
||||||
}}
|
}}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
expect(
|
expect(screen.getByText("Missing: unit, integration")).toBeInTheDocument();
|
||||||
screen.getByText("Missing: unit, integration"),
|
|
||||||
).toBeInTheDocument();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows warning text", () => {
|
it("shows warning text", () => {
|
||||||
@@ -101,6 +100,7 @@ describe("GatePanel", () => {
|
|||||||
warning: "Multiple tests failing — fix one at a time.",
|
warning: "Multiple tests failing — fix one at a time.",
|
||||||
summary: { total: 4, passed: 2, failed: 2 },
|
summary: { total: 4, passed: 2, failed: 2 },
|
||||||
missingCategories: [],
|
missingCategories: [],
|
||||||
|
coverageReport: null,
|
||||||
}}
|
}}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
@@ -119,12 +119,11 @@ describe("GatePanel", () => {
|
|||||||
warning: null,
|
warning: null,
|
||||||
summary: { total: 2, passed: 1, failed: 1 },
|
summary: { total: 2, passed: 1, failed: 1 },
|
||||||
missingCategories: [],
|
missingCategories: [],
|
||||||
|
coverageReport: null,
|
||||||
}}
|
}}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
expect(
|
expect(screen.getByText("No approved test plan.")).toBeInTheDocument();
|
||||||
screen.getByText("No approved test plan."),
|
|
||||||
).toBeInTheDocument();
|
|
||||||
expect(screen.getByText("Tests are failing.")).toBeInTheDocument();
|
expect(screen.getByText("Tests are failing.")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -132,9 +131,7 @@ describe("GatePanel", () => {
|
|||||||
const onRefresh = vi.fn();
|
const onRefresh = vi.fn();
|
||||||
render(<GatePanel {...baseProps} onRefresh={onRefresh} />);
|
render(<GatePanel {...baseProps} onRefresh={onRefresh} />);
|
||||||
|
|
||||||
await userEvent.click(
|
await userEvent.click(screen.getByRole("button", { name: "Refresh" }));
|
||||||
screen.getByRole("button", { name: "Refresh" }),
|
|
||||||
);
|
|
||||||
expect(onRefresh).toHaveBeenCalledOnce();
|
expect(onRefresh).toHaveBeenCalledOnce();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
76
frontend/src/components/UpcomingPanel.test.tsx
Normal file
76
frontend/src/components/UpcomingPanel.test.tsx
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import type { UpcomingStory } from "../api/workflow";
|
||||||
|
import { UpcomingPanel } from "./UpcomingPanel";
|
||||||
|
|
||||||
|
const baseProps = {
|
||||||
|
stories: [] as UpcomingStory[],
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
lastRefresh: null,
|
||||||
|
onRefresh: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("UpcomingPanel", () => {
|
||||||
|
it("shows empty state when no stories", () => {
|
||||||
|
render(<UpcomingPanel {...baseProps} />);
|
||||||
|
expect(screen.getByText("No upcoming stories.")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows loading state", () => {
|
||||||
|
render(<UpcomingPanel {...baseProps} isLoading={true} />);
|
||||||
|
expect(screen.getByText("Loading upcoming stories...")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows error with retry button", async () => {
|
||||||
|
const onRefresh = vi.fn();
|
||||||
|
render(
|
||||||
|
<UpcomingPanel
|
||||||
|
{...baseProps}
|
||||||
|
error="Network error"
|
||||||
|
onRefresh={onRefresh}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.getByText(/Network error.*Use Refresh to try again\./),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: "Retry" }));
|
||||||
|
expect(onRefresh).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders story list with names", () => {
|
||||||
|
const stories: UpcomingStory[] = [
|
||||||
|
{ story_id: "31_view_upcoming", name: "View Upcoming Stories" },
|
||||||
|
{ story_id: "32_worktree", name: "Worktree Orchestration" },
|
||||||
|
];
|
||||||
|
render(<UpcomingPanel {...baseProps} stories={stories} />);
|
||||||
|
|
||||||
|
expect(screen.getByText("View Upcoming Stories")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Worktree Orchestration")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("31_view_upcoming")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("32_worktree")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders story without name using story_id", () => {
|
||||||
|
const stories: UpcomingStory[] = [{ story_id: "33_no_name", name: null }];
|
||||||
|
render(<UpcomingPanel {...baseProps} stories={stories} />);
|
||||||
|
|
||||||
|
expect(screen.getByText("33_no_name")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls onRefresh when Refresh clicked", async () => {
|
||||||
|
const onRefresh = vi.fn();
|
||||||
|
render(<UpcomingPanel {...baseProps} onRefresh={onRefresh} />);
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: "Refresh" }));
|
||||||
|
expect(onRefresh).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("disables Refresh while loading", () => {
|
||||||
|
render(<UpcomingPanel {...baseProps} isLoading={true} />);
|
||||||
|
expect(screen.getByRole("button", { name: "Refresh" })).toBeDisabled();
|
||||||
|
});
|
||||||
|
});
|
||||||
169
frontend/src/components/UpcomingPanel.tsx
Normal file
169
frontend/src/components/UpcomingPanel.tsx
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
import type { UpcomingStory } from "../api/workflow";
|
||||||
|
|
||||||
|
interface UpcomingPanelProps {
|
||||||
|
stories: UpcomingStory[];
|
||||||
|
isLoading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
lastRefresh: Date | null;
|
||||||
|
onRefresh: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatTimestamp = (value: Date | null): string => {
|
||||||
|
if (!value) return "—";
|
||||||
|
return value.toLocaleTimeString([], {
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
second: "2-digit",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export function UpcomingPanel({
|
||||||
|
stories,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
lastRefresh,
|
||||||
|
onRefresh,
|
||||||
|
}: UpcomingPanelProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
border: "1px solid #333",
|
||||||
|
borderRadius: "10px",
|
||||||
|
padding: "12px 16px",
|
||||||
|
background: "#1f1f1f",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: "8px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
gap: "12px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "8px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ fontWeight: 600 }}>Upcoming Stories</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onRefresh}
|
||||||
|
disabled={isLoading}
|
||||||
|
style={{
|
||||||
|
padding: "4px 10px",
|
||||||
|
borderRadius: "999px",
|
||||||
|
border: "1px solid #333",
|
||||||
|
background: isLoading ? "#2a2a2a" : "#2f2f2f",
|
||||||
|
color: isLoading ? "#777" : "#aaa",
|
||||||
|
cursor: isLoading ? "not-allowed" : "pointer",
|
||||||
|
fontSize: "0.75em",
|
||||||
|
fontWeight: 600,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Refresh
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
alignItems: "flex-end",
|
||||||
|
gap: "2px",
|
||||||
|
fontSize: "0.85em",
|
||||||
|
color: "#aaa",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div>{stories.length} stories</div>
|
||||||
|
<div style={{ fontSize: "0.8em", color: "#777" }}>
|
||||||
|
Updated {formatTimestamp(lastRefresh)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<div style={{ fontSize: "0.85em", color: "#aaa" }}>
|
||||||
|
Loading upcoming stories...
|
||||||
|
</div>
|
||||||
|
) : error ? (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: "0.85em",
|
||||||
|
color: "#ff7b72",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "8px",
|
||||||
|
flexWrap: "wrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>{error} Use Refresh to try again.</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onRefresh}
|
||||||
|
disabled={isLoading}
|
||||||
|
style={{
|
||||||
|
padding: "4px 10px",
|
||||||
|
borderRadius: "999px",
|
||||||
|
border: "1px solid #333",
|
||||||
|
background: "#2f2f2f",
|
||||||
|
color: "#aaa",
|
||||||
|
cursor: "pointer",
|
||||||
|
fontSize: "0.75em",
|
||||||
|
fontWeight: 600,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Retry
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : stories.length === 0 ? (
|
||||||
|
<div style={{ fontSize: "0.85em", color: "#aaa" }}>
|
||||||
|
No upcoming stories.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: "6px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{stories.map((story) => (
|
||||||
|
<div
|
||||||
|
key={`upcoming-${story.story_id}`}
|
||||||
|
style={{
|
||||||
|
border: "1px solid #2a2a2a",
|
||||||
|
borderRadius: "8px",
|
||||||
|
padding: "8px 12px",
|
||||||
|
background: "#191919",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "8px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ fontWeight: 600, fontSize: "0.9em" }}>
|
||||||
|
{story.name ?? story.story_id}
|
||||||
|
</div>
|
||||||
|
{story.name && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: "0.75em",
|
||||||
|
color: "#777",
|
||||||
|
fontFamily: "monospace",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{story.story_id}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { act, renderHook, waitFor } from "@testing-library/react";
|
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||||
import { describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import type { FileEntry } from "./usePathCompletion";
|
import type { FileEntry } from "./usePathCompletion";
|
||||||
import {
|
import {
|
||||||
getCurrentPartial,
|
getCurrentPartial,
|
||||||
|
|||||||
@@ -11,6 +11,20 @@ pub struct AppContext {
|
|||||||
pub workflow: Arc<std::sync::Mutex<WorkflowState>>,
|
pub workflow: Arc<std::sync::Mutex<WorkflowState>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
impl AppContext {
|
||||||
|
pub fn new_test(project_root: std::path::PathBuf) -> Self {
|
||||||
|
let state = SessionState::default();
|
||||||
|
*state.project_root.lock().unwrap() = Some(project_root.clone());
|
||||||
|
let store_path = project_root.join(".story_kit_store.json");
|
||||||
|
Self {
|
||||||
|
state: Arc::new(state),
|
||||||
|
store: Arc::new(JsonFileStore::new(store_path).unwrap()),
|
||||||
|
workflow: Arc::new(std::sync::Mutex::new(WorkflowState::default())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub type OpenApiResult<T> = poem::Result<T>;
|
pub type OpenApiResult<T> = poem::Result<T>;
|
||||||
|
|
||||||
pub fn bad_request(message: String) -> poem::Error {
|
pub fn bad_request(message: String) -> poem::Error {
|
||||||
|
|||||||
@@ -87,6 +87,51 @@ struct ReviewListResponse {
|
|||||||
pub stories: Vec<ReviewStory>,
|
pub stories: Vec<ReviewStory>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Object)]
|
||||||
|
struct UpcomingStory {
|
||||||
|
pub story_id: String,
|
||||||
|
pub name: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Object)]
|
||||||
|
struct UpcomingStoriesResponse {
|
||||||
|
pub stories: Vec<UpcomingStory>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_upcoming_stories(ctx: &AppContext) -> Result<Vec<UpcomingStory>, String> {
|
||||||
|
let root = ctx.state.get_project_root()?;
|
||||||
|
let upcoming_dir = root.join(".story_kit").join("stories").join("upcoming");
|
||||||
|
|
||||||
|
if !upcoming_dir.exists() {
|
||||||
|
return Ok(Vec::new());
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut stories = Vec::new();
|
||||||
|
for entry in fs::read_dir(&upcoming_dir)
|
||||||
|
.map_err(|e| format!("Failed to read upcoming stories directory: {e}"))?
|
||||||
|
{
|
||||||
|
let entry = entry.map_err(|e| format!("Failed to read upcoming story entry: {e}"))?;
|
||||||
|
let path = entry.path();
|
||||||
|
if path.extension().and_then(|ext| ext.to_str()) != Some("md") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let story_id = path
|
||||||
|
.file_stem()
|
||||||
|
.and_then(|stem| stem.to_str())
|
||||||
|
.ok_or_else(|| "Invalid story file name.".to_string())?
|
||||||
|
.to_string();
|
||||||
|
let contents = fs::read_to_string(&path)
|
||||||
|
.map_err(|e| format!("Failed to read story file {}: {e}", path.display()))?;
|
||||||
|
let name = parse_front_matter(&contents)
|
||||||
|
.ok()
|
||||||
|
.and_then(|meta| meta.name);
|
||||||
|
stories.push(UpcomingStory { story_id, name });
|
||||||
|
}
|
||||||
|
|
||||||
|
stories.sort_by(|a, b| a.story_id.cmp(&b.story_id));
|
||||||
|
Ok(stories)
|
||||||
|
}
|
||||||
|
|
||||||
fn load_current_story_metadata(ctx: &AppContext) -> Result<Vec<(String, StoryMetadata)>, String> {
|
fn load_current_story_metadata(ctx: &AppContext) -> Result<Vec<(String, StoryMetadata)>, String> {
|
||||||
let root = ctx.state.get_project_root()?;
|
let root = ctx.state.get_project_root()?;
|
||||||
let current_dir = root.join(".story_kit").join("stories").join("current");
|
let current_dir = root.join(".story_kit").join("stories").join("current");
|
||||||
@@ -403,6 +448,13 @@ impl WorkflowApi {
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// List upcoming stories from .story_kit/stories/upcoming/.
|
||||||
|
#[oai(path = "/workflow/upcoming", method = "get")]
|
||||||
|
async fn list_upcoming_stories(&self) -> OpenApiResult<Json<UpcomingStoriesResponse>> {
|
||||||
|
let stories = load_upcoming_stories(self.ctx.as_ref()).map_err(bad_request)?;
|
||||||
|
Ok(Json(UpcomingStoriesResponse { stories }))
|
||||||
|
}
|
||||||
|
|
||||||
/// Ensure a story can be accepted; returns an error when gates fail.
|
/// Ensure a story can be accepted; returns an error when gates fail.
|
||||||
#[oai(path = "/workflow/acceptance/ensure", method = "post")]
|
#[oai(path = "/workflow/acceptance/ensure", method = "post")]
|
||||||
async fn ensure_acceptance(
|
async fn ensure_acceptance(
|
||||||
@@ -554,4 +606,57 @@ mod tests {
|
|||||||
assert!(!review.can_accept);
|
assert!(!review.can_accept);
|
||||||
assert_eq!(review.summary.failed, 1);
|
assert_eq!(review.summary.failed, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn load_upcoming_returns_empty_when_no_dir() {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let root = tmp.path().to_path_buf();
|
||||||
|
// No .story_kit directory at all
|
||||||
|
let ctx = crate::http::context::AppContext::new_test(root);
|
||||||
|
let result = load_upcoming_stories(&ctx).unwrap();
|
||||||
|
assert!(result.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn load_upcoming_parses_metadata() {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let upcoming = tmp.path().join(".story_kit/stories/upcoming");
|
||||||
|
fs::create_dir_all(&upcoming).unwrap();
|
||||||
|
fs::write(
|
||||||
|
upcoming.join("31_view_upcoming.md"),
|
||||||
|
"---\nname: View Upcoming\ntest_plan: pending\n---\n# Story\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
fs::write(
|
||||||
|
upcoming.join("32_worktree.md"),
|
||||||
|
"---\nname: Worktree Orchestration\ntest_plan: pending\n---\n# Story\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let ctx = crate::http::context::AppContext::new_test(tmp.path().to_path_buf());
|
||||||
|
let stories = load_upcoming_stories(&ctx).unwrap();
|
||||||
|
assert_eq!(stories.len(), 2);
|
||||||
|
assert_eq!(stories[0].story_id, "31_view_upcoming");
|
||||||
|
assert_eq!(stories[0].name.as_deref(), Some("View Upcoming"));
|
||||||
|
assert_eq!(stories[1].story_id, "32_worktree");
|
||||||
|
assert_eq!(stories[1].name.as_deref(), Some("Worktree Orchestration"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn load_upcoming_skips_non_md_files() {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let upcoming = tmp.path().join(".story_kit/stories/upcoming");
|
||||||
|
fs::create_dir_all(&upcoming).unwrap();
|
||||||
|
fs::write(upcoming.join(".gitkeep"), "").unwrap();
|
||||||
|
fs::write(
|
||||||
|
upcoming.join("31_story.md"),
|
||||||
|
"---\nname: A Story\ntest_plan: pending\n---\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let ctx = crate::http::context::AppContext::new_test(tmp.path().to_path_buf());
|
||||||
|
let stories = load_upcoming_stories(&ctx).unwrap();
|
||||||
|
assert_eq!(stories.len(), 1);
|
||||||
|
assert_eq!(stories[0].story_id, "31_story");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user