From 939387104b4670b3de89c7d22295cf02a2f08c67 Mon Sep 17 00:00:00 2001 From: Dave Date: Thu, 19 Feb 2026 15:51:12 +0000 Subject: [PATCH] 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 --- .../31_view_upcoming_stories.md | 2 +- frontend/src/App.test.tsx | 1 + frontend/src/api/workflow.test.ts | 22 +++ frontend/src/api/workflow.ts | 16 ++ frontend/src/components/Chat.test.tsx | 20 +++ frontend/src/components/Chat.tsx | 69 ++++++- frontend/src/components/GatePanel.test.tsx | 27 ++- .../src/components/UpcomingPanel.test.tsx | 76 ++++++++ frontend/src/components/UpcomingPanel.tsx | 169 ++++++++++++++++++ .../selection/usePathCompletion.test.ts | 2 +- server/src/http/context.rs | 14 ++ server/src/http/workflow.rs | 105 +++++++++++ 12 files changed, 505 insertions(+), 18 deletions(-) rename .story_kit/stories/{upcoming => archived}/31_view_upcoming_stories.md (96%) create mode 100644 frontend/src/components/UpcomingPanel.test.tsx create mode 100644 frontend/src/components/UpcomingPanel.tsx diff --git a/.story_kit/stories/upcoming/31_view_upcoming_stories.md b/.story_kit/stories/archived/31_view_upcoming_stories.md similarity index 96% rename from .story_kit/stories/upcoming/31_view_upcoming_stories.md rename to .story_kit/stories/archived/31_view_upcoming_stories.md index c2fd7ee..f630bbb 100644 --- a/.story_kit/stories/upcoming/31_view_upcoming_stories.md +++ b/.story_kit/stories/archived/31_view_upcoming_stories.md @@ -1,6 +1,6 @@ --- name: View Upcoming Stories -test_plan: pending +test_plan: approved --- # Story 31: View Upcoming Stories diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index a949750..91a3b90 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -40,6 +40,7 @@ vi.mock("./api/workflow", () => { missing_categories: [], }), getReviewQueueAll: vi.fn().mockResolvedValue({ stories: [] }), + getUpcomingStories: vi.fn().mockResolvedValue({ stories: [] }), recordTests: vi.fn(), ensureAcceptance: vi.fn(), getReviewQueue: vi.fn(), diff --git a/frontend/src/api/workflow.test.ts b/frontend/src/api/workflow.test.ts index 0d68f0c..c059452 100644 --- a/frontend/src/api/workflow.test.ts +++ b/frontend/src/api/workflow.test.ts @@ -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", () => { it("sends GET to /workflow/review", async () => { mockFetch.mockResolvedValueOnce( diff --git a/frontend/src/api/workflow.ts b/frontend/src/api/workflow.ts index e2d3f33..204986a 100644 --- a/frontend/src/api/workflow.ts +++ b/frontend/src/api/workflow.ts @@ -62,6 +62,15 @@ export interface ReviewListResponse { stories: ReviewStory[]; } +export interface UpcomingStory { + story_id: string; + name: string | null; +} + +export interface UpcomingStoriesResponse { + stories: UpcomingStory[]; +} + const DEFAULT_API_BASE = "/api"; function buildApiUrl(path: string, baseUrl = DEFAULT_API_BASE): string { @@ -124,6 +133,13 @@ export const workflowApi = { getReviewQueueAll(baseUrl?: string) { return requestJson("/workflow/review/all", {}, baseUrl); }, + getUpcomingStories(baseUrl?: string) { + return requestJson( + "/workflow/upcoming", + {}, + baseUrl, + ); + }, ensureAcceptance(payload: AcceptanceRequest, baseUrl?: string) { return requestJson( "/workflow/acceptance/ensure", diff --git a/frontend/src/components/Chat.test.tsx b/frontend/src/components/Chat.test.tsx index ddcb145..1ad8440 100644 --- a/frontend/src/components/Chat.test.tsx +++ b/frontend/src/components/Chat.test.tsx @@ -35,6 +35,7 @@ vi.mock("../api/workflow", () => { ensureAcceptance: vi.fn(), recordCoverage: vi.fn(), collectCoverage: vi.fn(), + getUpcomingStories: vi.fn(), }, }; }); @@ -54,6 +55,7 @@ const mockedWorkflow = { getReviewQueue: vi.mocked(workflowApi.getReviewQueue), getReviewQueueAll: vi.mocked(workflowApi.getReviewQueueAll), ensureAcceptance: vi.mocked(workflowApi.ensureAcceptance), + getUpcomingStories: vi.mocked(workflowApi.getUpcomingStories), }; describe("Chat review panel", () => { @@ -75,6 +77,7 @@ describe("Chat review panel", () => { }); mockedWorkflow.getReviewQueueAll.mockResolvedValue({ stories: [] }); mockedWorkflow.ensureAcceptance.mockResolvedValue(true); + mockedWorkflow.getUpcomingStories.mockResolvedValue({ stories: [] }); }); it("shows an empty review queue state", async () => { @@ -466,6 +469,23 @@ describe("Chat review panel", () => { 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(); + + 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 () => { const mockedCollectCoverage = vi.mocked(workflowApi.collectCoverage); mockedCollectCoverage.mockResolvedValueOnce({ diff --git a/frontend/src/components/Chat.tsx b/frontend/src/components/Chat.tsx index 7807862..e13722a 100644 --- a/frontend/src/components/Chat.tsx +++ b/frontend/src/components/Chat.tsx @@ -3,12 +3,13 @@ import Markdown from "react-markdown"; import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"; import { oneDark } from "react-syntax-highlighter/dist/esm/styles/prism"; 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 type { Message, ProviderConfig, ToolCall } from "../types"; import { ChatHeader } from "./ChatHeader"; import { GatePanel } from "./GatePanel"; import { ReviewPanel } from "./ReviewPanel"; +import { UpcomingPanel } from "./UpcomingPanel"; const { useCallback, useEffect, useRef, useState } = React; @@ -61,6 +62,12 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) { const [lastGateRefresh, setLastGateRefresh] = useState(null); const [isCollectingCoverage, setIsCollectingCoverage] = useState(false); const [coverageError, setCoverageError] = useState(null); + const [upcomingStories, setUpcomingStories] = useState([]); + const [upcomingError, setUpcomingError] = useState(null); + const [isUpcomingLoading, setIsUpcomingLoading] = useState(false); + const [lastUpcomingRefresh, setLastUpcomingRefresh] = useState( + null, + ); const storyId = "26_establish_tdd_workflow_and_gates"; 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 () => { setIsReviewLoading(true); setReviewError(null); @@ -593,6 +652,14 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) { onCollectCoverage={handleCollectCoverage} isCollectingCoverage={isCollectingCoverage} /> + + diff --git a/frontend/src/components/GatePanel.test.tsx b/frontend/src/components/GatePanel.test.tsx index fa53004..ef480d6 100644 --- a/frontend/src/components/GatePanel.test.tsx +++ b/frontend/src/components/GatePanel.test.tsx @@ -9,8 +9,11 @@ const baseProps = { gateStatusColor: "#aaa", isGateLoading: false, gateError: null, + coverageError: null, lastGateRefresh: null, onRefresh: vi.fn(), + onCollectCoverage: vi.fn(), + isCollectingCoverage: false, }; describe("GatePanel", () => { @@ -21,9 +24,7 @@ describe("GatePanel", () => { it("shows loading message when isGateLoading is true", () => { render(); - expect( - screen.getByText("Loading workflow gates..."), - ).toBeInTheDocument(); + expect(screen.getByText("Loading workflow gates...")).toBeInTheDocument(); }); it("shows error with retry button", async () => { @@ -64,13 +65,12 @@ describe("GatePanel", () => { warning: null, summary: { total: 5, passed: 5, failed: 0 }, missingCategories: [], + coverageReport: null, }} gateStatusLabel="Ready to accept" />, ); - expect( - screen.getByText(/5\/5 passing, 0 failing/), - ).toBeInTheDocument(); + expect(screen.getByText(/5\/5 passing, 0 failing/)).toBeInTheDocument(); }); it("shows missing categories", () => { @@ -83,12 +83,11 @@ describe("GatePanel", () => { warning: null, summary: { total: 0, passed: 0, failed: 0 }, missingCategories: ["unit", "integration"], + coverageReport: null, }} />, ); - expect( - screen.getByText("Missing: unit, integration"), - ).toBeInTheDocument(); + expect(screen.getByText("Missing: unit, integration")).toBeInTheDocument(); }); it("shows warning text", () => { @@ -101,6 +100,7 @@ describe("GatePanel", () => { warning: "Multiple tests failing — fix one at a time.", summary: { total: 4, passed: 2, failed: 2 }, missingCategories: [], + coverageReport: null, }} />, ); @@ -119,12 +119,11 @@ describe("GatePanel", () => { warning: null, summary: { total: 2, passed: 1, failed: 1 }, missingCategories: [], + coverageReport: null, }} />, ); - expect( - screen.getByText("No approved test plan."), - ).toBeInTheDocument(); + expect(screen.getByText("No approved test plan.")).toBeInTheDocument(); expect(screen.getByText("Tests are failing.")).toBeInTheDocument(); }); @@ -132,9 +131,7 @@ describe("GatePanel", () => { const onRefresh = vi.fn(); render(); - await userEvent.click( - screen.getByRole("button", { name: "Refresh" }), - ); + await userEvent.click(screen.getByRole("button", { name: "Refresh" })); expect(onRefresh).toHaveBeenCalledOnce(); }); diff --git a/frontend/src/components/UpcomingPanel.test.tsx b/frontend/src/components/UpcomingPanel.test.tsx new file mode 100644 index 0000000..f63e7f8 --- /dev/null +++ b/frontend/src/components/UpcomingPanel.test.tsx @@ -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(); + expect(screen.getByText("No upcoming stories.")).toBeInTheDocument(); + }); + + it("shows loading state", () => { + render(); + expect(screen.getByText("Loading upcoming stories...")).toBeInTheDocument(); + }); + + it("shows error with retry button", async () => { + const onRefresh = vi.fn(); + render( + , + ); + + 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(); + + 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(); + + expect(screen.getByText("33_no_name")).toBeInTheDocument(); + }); + + it("calls onRefresh when Refresh clicked", async () => { + const onRefresh = vi.fn(); + render(); + + await userEvent.click(screen.getByRole("button", { name: "Refresh" })); + expect(onRefresh).toHaveBeenCalledOnce(); + }); + + it("disables Refresh while loading", () => { + render(); + expect(screen.getByRole("button", { name: "Refresh" })).toBeDisabled(); + }); +}); diff --git a/frontend/src/components/UpcomingPanel.tsx b/frontend/src/components/UpcomingPanel.tsx new file mode 100644 index 0000000..3a5b497 --- /dev/null +++ b/frontend/src/components/UpcomingPanel.tsx @@ -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 ( +
+
+
+
Upcoming Stories
+ +
+
+
{stories.length} stories
+
+ Updated {formatTimestamp(lastRefresh)} +
+
+
+ + {isLoading ? ( +
+ Loading upcoming stories... +
+ ) : error ? ( +
+ {error} Use Refresh to try again. + +
+ ) : stories.length === 0 ? ( +
+ No upcoming stories. +
+ ) : ( +
+ {stories.map((story) => ( +
+
+ {story.name ?? story.story_id} +
+ {story.name && ( +
+ {story.story_id} +
+ )} +
+ ))} +
+ )} +
+ ); +} diff --git a/frontend/src/components/selection/usePathCompletion.test.ts b/frontend/src/components/selection/usePathCompletion.test.ts index ab785f6..d33df8e 100644 --- a/frontend/src/components/selection/usePathCompletion.test.ts +++ b/frontend/src/components/selection/usePathCompletion.test.ts @@ -1,5 +1,5 @@ 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 { getCurrentPartial, diff --git a/server/src/http/context.rs b/server/src/http/context.rs index bfd6f58..ef160d3 100644 --- a/server/src/http/context.rs +++ b/server/src/http/context.rs @@ -11,6 +11,20 @@ pub struct AppContext { pub workflow: Arc>, } +#[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 = poem::Result; pub fn bad_request(message: String) -> poem::Error { diff --git a/server/src/http/workflow.rs b/server/src/http/workflow.rs index 82d9b2e..4e64a06 100644 --- a/server/src/http/workflow.rs +++ b/server/src/http/workflow.rs @@ -87,6 +87,51 @@ struct ReviewListResponse { pub stories: Vec, } +#[derive(Object)] +struct UpcomingStory { + pub story_id: String, + pub name: Option, +} + +#[derive(Object)] +struct UpcomingStoriesResponse { + pub stories: Vec, +} + +fn load_upcoming_stories(ctx: &AppContext) -> Result, 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, String> { let root = ctx.state.get_project_root()?; 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> { + 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. #[oai(path = "/workflow/acceptance/ensure", method = "post")] async fn ensure_acceptance( @@ -554,4 +606,57 @@ mod tests { assert!(!review.can_accept); 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"); + } }