feat(FN-4719): complete Step 3-4 — goals view lazy wiring and changeset
Fusion-Task-Id: FN-4719 Fusion-Task-Lineage: 53fb716e-2d3e-40c7-ad7a-e9f315f33c34
This commit is contained in:
committed by
gsxdsm
parent
3c2afc4488
commit
b94eae4f23
5
.changeset/fn-4719-goals-view-slice-1.md
Normal file
5
.changeset/fn-4719-goals-view-slice-1.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": minor
|
||||||
|
---
|
||||||
|
|
||||||
|
Add minimal Goals dashboard view (Slice 1 of Goals-as-strategic-layer mission). Lazy-loaded component renders active-goal list with soft-warning at 3 active goals and hard-error when attempting to exceed the 5-active cap. Uses mock data adapter pending the GoalStore backend slice.
|
||||||
@@ -105,6 +105,7 @@ const MemoryView = lazy(() => import("./components/MemoryView").then((m) => ({ d
|
|||||||
const ReliabilityView = lazy(() => import("./components/ReliabilityView").then((m) => ({ default: m.ReliabilityView })));
|
const ReliabilityView = lazy(() => import("./components/ReliabilityView").then((m) => ({ default: m.ReliabilityView })));
|
||||||
const DevServerView = lazy(() => import("./components/DevServerView").then((m) => ({ default: m.DevServerView })));
|
const DevServerView = lazy(() => import("./components/DevServerView").then((m) => ({ default: m.DevServerView })));
|
||||||
const _TodoView = lazy(() => import("./components/TodoView").then((m) => ({ default: m.TodoView })));
|
const _TodoView = lazy(() => import("./components/TodoView").then((m) => ({ default: m.TodoView })));
|
||||||
|
const _GoalsView = lazy(() => import("./components/GoalsView").then((m) => ({ default: m.GoalsView })));
|
||||||
const StashRecoveryView = lazy(() => import("./components/StashRecoveryView").then((m) => ({ default: m.StashRecoveryView })));
|
const StashRecoveryView = lazy(() => import("./components/StashRecoveryView").then((m) => ({ default: m.StashRecoveryView })));
|
||||||
|
|
||||||
// Warm lazy chunks during browser idle so first navigation to each view is
|
// Warm lazy chunks during browser idle so first navigation to each view is
|
||||||
@@ -132,6 +133,7 @@ function prefetchLazyViews() {
|
|||||||
void import("./components/ReliabilityView");
|
void import("./components/ReliabilityView");
|
||||||
void import("./components/DevServerView");
|
void import("./components/DevServerView");
|
||||||
void import("./components/TodoView");
|
void import("./components/TodoView");
|
||||||
|
void import("./components/GoalsView");
|
||||||
void import("./components/StashRecoveryView");
|
void import("./components/StashRecoveryView");
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
104
packages/dashboard/app/components/GoalsView.css
Normal file
104
packages/dashboard/app/components/GoalsView.css
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
.goals-view {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-lg);
|
||||||
|
padding: var(--space-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.goals-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.goals-title {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text);
|
||||||
|
font-size: calc(var(--space-lg) + var(--space-xs));
|
||||||
|
}
|
||||||
|
|
||||||
|
.goals-count {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.goals-add-button {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.goals-add-button:focus-visible,
|
||||||
|
.goals-card:focus-visible,
|
||||||
|
.goals-activate-button:focus-visible {
|
||||||
|
outline: none;
|
||||||
|
box-shadow: var(--focus-ring-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.goals-warning {
|
||||||
|
margin: 0;
|
||||||
|
padding: var(--space-md);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
border: calc(var(--space-xs) / 4) solid var(--color-warning);
|
||||||
|
background: color-mix(in srgb, var(--color-warning) 10%, transparent);
|
||||||
|
color: var(--color-warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.goals-error {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.goals-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.goals-card {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--space-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.goals-card-main {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.goals-card-title {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text);
|
||||||
|
font-size: calc(var(--space-md) + var(--space-xs));
|
||||||
|
}
|
||||||
|
|
||||||
|
.goals-card-status {
|
||||||
|
margin: var(--space-xs) 0 0;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.goals-activate-button {
|
||||||
|
min-width: calc(var(--space-2xl) * 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.goals-empty {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.goals-header {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.goals-add-button,
|
||||||
|
.goals-card,
|
||||||
|
.goals-activate-button {
|
||||||
|
min-height: calc(var(--space-xl) + var(--space-md));
|
||||||
|
}
|
||||||
|
|
||||||
|
.goals-card {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
}
|
||||||
139
packages/dashboard/app/components/GoalsView.tsx
Normal file
139
packages/dashboard/app/components/GoalsView.tsx
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { Plus } from "lucide-react";
|
||||||
|
import "./GoalsView.css";
|
||||||
|
|
||||||
|
interface Goal {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
status: "active" | "inactive";
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseGoalsResult {
|
||||||
|
goals: Goal[];
|
||||||
|
activeCount: number;
|
||||||
|
errorMessage: string | null;
|
||||||
|
addGoal: () => void;
|
||||||
|
activateGoal: (goalId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GoalsViewProps {
|
||||||
|
initialGoals?: Goal[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_ACTIVE_GOALS = 5;
|
||||||
|
const WARNING_THRESHOLD = 3;
|
||||||
|
|
||||||
|
const defaultMockGoals: Goal[] = [
|
||||||
|
{ id: "goal-1", title: "Reduce mean review turnaround", status: "active", createdAt: "2026-05-14T09:30:00.000Z" },
|
||||||
|
{ id: "goal-2", title: "Raise merge reliability coverage", status: "active", createdAt: "2026-05-15T12:00:00.000Z" },
|
||||||
|
{ id: "goal-3", title: "Ship dashboard quality audit", status: "inactive", createdAt: "2026-05-16T08:15:00.000Z" },
|
||||||
|
];
|
||||||
|
|
||||||
|
function useGoals(initialGoals?: Goal[]): UseGoalsResult {
|
||||||
|
const [goals, setGoals] = useState<Goal[]>(() => initialGoals ?? defaultMockGoals);
|
||||||
|
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const activeCount = useMemo(() => goals.filter((goal) => goal.status === "active").length, [goals]);
|
||||||
|
|
||||||
|
function addGoal() {
|
||||||
|
if (activeCount >= MAX_ACTIVE_GOALS) {
|
||||||
|
setErrorMessage("Cannot activate more than 5 goals. Resolve an active goal before adding another active goal.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setErrorMessage(null);
|
||||||
|
setGoals((current) => [
|
||||||
|
...current,
|
||||||
|
{
|
||||||
|
id: `goal-${current.length + 1}`,
|
||||||
|
title: `New Goal ${current.length + 1}`,
|
||||||
|
status: "active",
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function activateGoal(goalId: string) {
|
||||||
|
const nextGoal = goals.find((goal) => goal.id === goalId);
|
||||||
|
if (!nextGoal || nextGoal.status === "active") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activeCount >= MAX_ACTIVE_GOALS) {
|
||||||
|
setErrorMessage("Cannot activate more than 5 goals. Resolve an active goal before activating another.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setErrorMessage(null);
|
||||||
|
setGoals((current) => current.map((goal) => (goal.id === goalId ? { ...goal, status: "active" } : goal)));
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
goals,
|
||||||
|
activeCount,
|
||||||
|
errorMessage,
|
||||||
|
addGoal,
|
||||||
|
activateGoal,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GoalsView({ initialGoals }: GoalsViewProps) {
|
||||||
|
const { goals, activeCount, errorMessage, addGoal, activateGoal } = useGoals(initialGoals);
|
||||||
|
const showWarning = activeCount >= WARNING_THRESHOLD && activeCount <= MAX_ACTIVE_GOALS;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="goals-view" data-testid="goals-view">
|
||||||
|
<header className="goals-header">
|
||||||
|
<div>
|
||||||
|
<h2 className="goals-title">Goals</h2>
|
||||||
|
<p className="goals-count" data-testid="goals-active-count">
|
||||||
|
{activeCount} active goals
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button type="button" className="btn btn-primary goals-add-button" onClick={addGoal} data-testid="goals-add-button">
|
||||||
|
<Plus aria-hidden="true" />
|
||||||
|
Add Goal
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{showWarning ? (
|
||||||
|
<p className="goals-warning" role="status">
|
||||||
|
Approaching the 5-active goal cap. Keep active goals focused.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{errorMessage ? (
|
||||||
|
<p className="form-error goals-error" role="alert">
|
||||||
|
{errorMessage}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{goals.length === 0 ? (
|
||||||
|
<div className="goals-empty card" data-testid="goals-empty-state">
|
||||||
|
No goals yet. Add one to begin tracking strategic outcomes.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="goals-list" data-testid="goals-list">
|
||||||
|
{goals.map((goal) => (
|
||||||
|
<article key={goal.id} className="card goals-card" data-testid={`goal-card-${goal.id}`}>
|
||||||
|
<div className="goals-card-main">
|
||||||
|
<h3 className="goals-card-title">{goal.title}</h3>
|
||||||
|
<p className="goals-card-status">Status: {goal.status}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn goals-activate-button"
|
||||||
|
disabled={goal.status === "active"}
|
||||||
|
onClick={() => activateGoal(goal.id)}
|
||||||
|
data-testid={`goal-activate-${goal.id}`}
|
||||||
|
>
|
||||||
|
{goal.status === "active" ? "Active" : "Activate"}
|
||||||
|
</button>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { loadAllAppCss } from "../../test/cssFixture";
|
||||||
|
|
||||||
|
function goalsBlocks(css: string): string {
|
||||||
|
const blocks = css.match(/[^\n]*\.goals-[^{\n]*\{[^}]*\}/gms) ?? [];
|
||||||
|
return blocks.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("GoalsView CSS token guardrails", () => {
|
||||||
|
it("uses tokens and contains mobile/focus rules", async () => {
|
||||||
|
const css = await loadAllAppCss();
|
||||||
|
const goalsCss = goalsBlocks(css);
|
||||||
|
|
||||||
|
expect(goalsCss).not.toMatch(/#[0-9a-fA-F]{3,8}/g);
|
||||||
|
expect(goalsCss).not.toMatch(/rgba?\(/g);
|
||||||
|
expect(goalsCss).not.toMatch(/\b[1-9]\d*px\b/g);
|
||||||
|
expect(css).toMatch(/\.goals-[^\n{]*:focus-visible/g);
|
||||||
|
expect(css).toMatch(/@media \(max-width: 768px\)[\s\S]*\.goals-/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { fireEvent, render, screen } from "@testing-library/react";
|
||||||
|
import { GoalsView } from "../GoalsView";
|
||||||
|
|
||||||
|
vi.mock("lucide-react", () => ({
|
||||||
|
Plus: () => <span data-testid="icon-plus" />,
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("GoalsView", () => {
|
||||||
|
it("renders empty state", () => {
|
||||||
|
render(<GoalsView initialGoals={[]} />);
|
||||||
|
expect(screen.getByTestId("goals-empty-state")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not show warning at 2 active goals", () => {
|
||||||
|
render(
|
||||||
|
<GoalsView
|
||||||
|
initialGoals={[
|
||||||
|
{ id: "g1", title: "One", status: "active", createdAt: "2026-05-16T00:00:00.000Z" },
|
||||||
|
{ id: "g2", title: "Two", status: "active", createdAt: "2026-05-16T00:00:00.000Z" },
|
||||||
|
]}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.queryByRole("status")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows warning at 3 active goals", () => {
|
||||||
|
render(
|
||||||
|
<GoalsView
|
||||||
|
initialGoals={[
|
||||||
|
{ id: "g1", title: "One", status: "active", createdAt: "2026-05-16T00:00:00.000Z" },
|
||||||
|
{ id: "g2", title: "Two", status: "active", createdAt: "2026-05-16T00:00:00.000Z" },
|
||||||
|
{ id: "g3", title: "Three", status: "active", createdAt: "2026-05-16T00:00:00.000Z" },
|
||||||
|
]}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.getByRole("status")).toHaveTextContent("5-active goal cap");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows hard error and prevents 6th activation when 5 are active", () => {
|
||||||
|
render(
|
||||||
|
<GoalsView
|
||||||
|
initialGoals={[
|
||||||
|
{ id: "g1", title: "One", status: "active", createdAt: "2026-05-16T00:00:00.000Z" },
|
||||||
|
{ id: "g2", title: "Two", status: "active", createdAt: "2026-05-16T00:00:00.000Z" },
|
||||||
|
{ id: "g3", title: "Three", status: "active", createdAt: "2026-05-16T00:00:00.000Z" },
|
||||||
|
{ id: "g4", title: "Four", status: "active", createdAt: "2026-05-16T00:00:00.000Z" },
|
||||||
|
{ id: "g5", title: "Five", status: "active", createdAt: "2026-05-16T00:00:00.000Z" },
|
||||||
|
{ id: "g6", title: "Six", status: "inactive", createdAt: "2026-05-16T00:00:00.000Z" },
|
||||||
|
]}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByTestId("goal-activate-g6"));
|
||||||
|
|
||||||
|
expect(screen.getByRole("alert")).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId("goal-activate-g6")).toHaveTextContent("Activate");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders add button with class for focus-visible style hook", () => {
|
||||||
|
render(<GoalsView initialGoals={[]} />);
|
||||||
|
expect(screen.getByTestId("goals-add-button")).toHaveClass("goals-add-button");
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user