Backend / staging cleanup, performance substrate, and create-flow polish #60

Merged
an.di merged 16 commits from adilallo/Backend/StagingCleanup into main 2026-05-26 15:11:47 +00:00
11 changed files with 261 additions and 58 deletions
Showing only changes of commit 8420ce42e3 - Show all commits
@@ -12,6 +12,7 @@ import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { CreateFlowProvider, useCreateFlow } from "./context/CreateFlowContext"; import { CreateFlowProvider, useCreateFlow } from "./context/CreateFlowContext";
import { useCreateFlowNavigation } from "./hooks/useCreateFlowNavigation"; import { useCreateFlowNavigation } from "./hooks/useCreateFlowNavigation";
import { useCreateFlowExit } from "./hooks/useCreateFlowExit"; import { useCreateFlowExit } from "./hooks/useCreateFlowExit";
import { usePrefetchMethodFacetRecommendations } from "./hooks/usePrefetchMethodFacetRecommendations";
import { useCreateFlowFinalize } from "./hooks/useCreateFlowFinalize"; import { useCreateFlowFinalize } from "./hooks/useCreateFlowFinalize";
import { useTemplateReviewActions } from "./hooks/useTemplateReviewActions"; import { useTemplateReviewActions } from "./hooks/useTemplateReviewActions";
import { useCompletedRuleShareExport } from "./hooks/useCompletedRuleShareExport"; import { useCompletedRuleShareExport } from "./hooks/useCompletedRuleShareExport";
@@ -167,6 +168,7 @@ function CreateFlowLayoutContent({
replaceState, replaceState,
markCreateFlowInteraction, markCreateFlowInteraction,
} = useCreateFlow(); } = useCreateFlow();
usePrefetchMethodFacetRecommendations();
const manageStakeholdersIntent = const manageStakeholdersIntent =
searchParams?.get(CREATE_FLOW_MANAGE_STAKEHOLDERS_QUERY) === searchParams?.get(CREATE_FLOW_MANAGE_STAKEHOLDERS_QUERY) ===
CREATE_FLOW_MANAGE_STAKEHOLDERS_VALUE; CREATE_FLOW_MANAGE_STAKEHOLDERS_VALUE;
@@ -1,8 +1,13 @@
"use client"; "use client";
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { buildFacetQueryString } from "../../../../lib/create/buildFacetQueryString"; import { buildFacetQueryString } from "../../../../lib/create/buildFacetQueryString";
import type { MethodFacetApiSectionId } from "../../../../lib/create/customRuleFacets"; import type { MethodFacetApiSectionId } from "../../../../lib/create/customRuleFacets";
import {
buildFacetRecommendationRequestKey,
getCachedFacetScores,
loadFacetScores,
} from "../../../../lib/create/facetRecommendationsLoad";
import { useCreateFlow } from "../context/CreateFlowContext"; import { useCreateFlow } from "../context/CreateFlowContext";
/** /**
@@ -25,6 +30,34 @@ export type FacetRecommendationsResult = {
const EMPTY_SCORES: Record<string, number> = {}; const EMPTY_SCORES: Record<string, number> = {};
function initialFacetRecommendationsResult(
section: RecommendationSection,
queryString: string,
): FacetRecommendationsResult {
const hasAnyFacets = queryString.length > 0;
if (!hasAnyFacets) {
return {
isReady: true,
scoresBySlug: EMPTY_SCORES,
hasAnyFacets: false,
};
}
const requestKey = buildFacetRecommendationRequestKey(section, queryString);
const cached = getCachedFacetScores(requestKey);
if (cached) {
return {
isReady: true,
scoresBySlug: cached,
hasAnyFacets: true,
};
}
return {
isReady: false,
scoresBySlug: EMPTY_SCORES,
hasAnyFacets: true,
};
}
/** /**
* Calls `GET /api/create-flow/methods?section=<section>&facet.*=...` for the * Calls `GET /api/create-flow/methods?section=<section>&facet.*=...` for the
* card-deck step `section` and returns a `slug → score` map for re-ranking * card-deck step `section` and returns a `slug → score` map for re-ranking
@@ -46,14 +79,9 @@ export function useFacetRecommendations(
); );
const hasAnyFacets = queryString.length > 0; const hasAnyFacets = queryString.length > 0;
const [result, setResult] = useState<FacetRecommendationsResult>({ const [result, setResult] = useState<FacetRecommendationsResult>(() =>
isReady: !hasAnyFacets, initialFacetRecommendationsResult(section, queryString),
scoresBySlug: EMPTY_SCORES, );
hasAnyFacets,
});
// Track the last successful request input so we don't re-fetch on every state poke.
const lastQueryRef = useRef<string | null>(null);
useEffect(() => { useEffect(() => {
if (!hasAnyFacets) { if (!hasAnyFacets) {
@@ -62,51 +90,34 @@ export function useFacetRecommendations(
scoresBySlug: EMPTY_SCORES, scoresBySlug: EMPTY_SCORES,
hasAnyFacets: false, hasAnyFacets: false,
}); });
lastQueryRef.current = null;
return; return;
} }
const requestKey = `${section}?${queryString}`;
if (lastQueryRef.current === requestKey) return;
lastQueryRef.current = requestKey;
const ctrl = new AbortController(); const requestKey = buildFacetRecommendationRequestKey(section, queryString);
setResult((prev) => ({ ...prev, isReady: false, hasAnyFacets: true })); const cached = getCachedFacetScores(requestKey);
fetch(`/api/create-flow/methods?section=${section}&${queryString}`, { if (cached) {
credentials: "include", setResult({
signal: ctrl.signal, isReady: true,
}) scoresBySlug: cached,
.then(async (res) => { hasAnyFacets: true,
if (!res.ok) throw new Error(`status ${res.status}`);
return (await res.json()) as {
methods?: { slug: string; matches?: { score?: number } }[];
};
})
.then((json) => {
const scoresBySlug: Record<string, number> = {};
for (const m of json.methods ?? []) {
if (typeof m.slug === "string") {
scoresBySlug[m.slug] = m.matches?.score ?? 0;
}
}
setResult({ isReady: true, scoresBySlug, hasAnyFacets: true });
})
.catch((e) => {
if ((e as { name?: string }).name === "AbortError") return;
setResult({
isReady: true,
scoresBySlug: EMPTY_SCORES,
hasAnyFacets: true,
});
}); });
return;
}
let cancelled = false;
setResult((prev) =>
prev.isReady && prev.hasAnyFacets
? { ...prev, isReady: false }
: { isReady: false, scoresBySlug: EMPTY_SCORES, hasAnyFacets: true },
);
void loadFacetScores(section, queryString).then((scoresBySlug) => {
if (cancelled) return;
setResult({ isReady: true, scoresBySlug, hasAnyFacets: true });
});
return () => { return () => {
ctrl.abort(); cancelled = true;
// Clear the dedup key so React 19 Strict Mode's mount → unmount → mount
// cycle (and any future remount) re-issues the request instead of
// returning early on the same key.
if (lastQueryRef.current === requestKey) {
lastQueryRef.current = null;
}
}; };
}, [section, queryString, hasAnyFacets]); }, [section, queryString, hasAnyFacets]);
@@ -25,7 +25,9 @@ export function useMethodCardDeckOrdering(
methods: readonly MethodEntry[], methods: readonly MethodEntry[],
selectedIds: readonly string[], selectedIds: readonly string[],
) { ) {
const { scoresBySlug, hasAnyFacets } = useFacetRecommendations(section); const { scoresBySlug, hasAnyFacets, isReady } =
useFacetRecommendations(section);
const recommendationsReady = !hasAnyFacets || isReady;
const rankedMethods = useMemo( const rankedMethods = useMemo(
() => rankMethodsByScore(methods, scoresBySlug), () => rankMethodsByScore(methods, scoresBySlug),
@@ -90,5 +92,6 @@ export function useMethodCardDeckOrdering(
recommendedIds, recommendedIds,
sampleCards, sampleCards,
methodById, methodById,
recommendationsReady,
}; };
} }
@@ -0,0 +1,27 @@
"use client";
import { useEffect, useMemo } from "react";
import { buildFacetQueryString } from "../../../../lib/create/buildFacetQueryString";
import { METHOD_FACET_API_SECTION_IDS } from "../../../../lib/create/customRuleFacets";
import { loadFacetScores } from "../../../../lib/create/facetRecommendationsLoad";
import { useCreateFlow } from "../context/CreateFlowContext";
/**
* Warms the facet recommendation cache for all method-deck sections once the
* user has community facet selections, so method screens can render ranked
* cards on first paint instead of flashing authoring order.
*/
export function usePrefetchMethodFacetRecommendations(): void {
const { state } = useCreateFlow();
const queryString = useMemo(
() => buildFacetQueryString(state),
[state],
);
useEffect(() => {
if (queryString.length === 0) return;
for (const section of METHOD_FACET_API_SECTION_IDS) {
void loadFacetScores(section, queryString);
}
}, [queryString]);
}
@@ -97,7 +97,8 @@ export function CommunicationMethodsScreen() {
[comm.methods, selectedIds, state.customMethodCardMetaById], [comm.methods, selectedIds, state.customMethodCardMetaById],
); );
const { sampleCards, compactCardIds, methodById } = useMethodCardDeckOrdering( const { sampleCards, compactCardIds, methodById, recommendationsReady } =
useMethodCardDeckOrdering(
"communication", "communication",
mergedMethods, mergedMethods,
selectedIds, selectedIds,
@@ -735,7 +736,11 @@ export function CommunicationMethodsScreen() {
justification="center" justification="center"
/> />
</div> </div>
<div className={CREATE_FLOW_CARD_STACK_AREA_MAX_CLASS}> <div
className={CREATE_FLOW_CARD_STACK_AREA_MAX_CLASS}
aria-busy={!recommendationsReady}
>
{recommendationsReady ? (
<CardStack <CardStack
cards={sampleCards} cards={sampleCards}
selectedIds={selectedIds} selectedIds={selectedIds}
@@ -752,6 +757,7 @@ export function CommunicationMethodsScreen() {
compactDesktopLayout="flexWrap" compactDesktopLayout="flexWrap"
headerLockupSize={mdUp ? "L" : "M"} headerLockupSize={mdUp ? "L" : "M"}
/> />
) : null}
</div> </div>
</div> </div>
@@ -94,7 +94,8 @@ export function ConflictManagementScreen() {
[cm.methods, selectedIds, state.customMethodCardMetaById], [cm.methods, selectedIds, state.customMethodCardMetaById],
); );
const { sampleCards, compactCardIds, methodById } = useMethodCardDeckOrdering( const { sampleCards, compactCardIds, methodById, recommendationsReady } =
useMethodCardDeckOrdering(
"conflictManagement", "conflictManagement",
mergedMethods, mergedMethods,
selectedIds, selectedIds,
@@ -734,7 +735,11 @@ export function ConflictManagementScreen() {
justification="center" justification="center"
/> />
</div> </div>
<div className={CREATE_FLOW_CARD_STACK_AREA_MAX_CLASS}> <div
className={CREATE_FLOW_CARD_STACK_AREA_MAX_CLASS}
aria-busy={!recommendationsReady}
>
{recommendationsReady ? (
<CardStack <CardStack
cards={sampleCards} cards={sampleCards}
selectedIds={selectedIds} selectedIds={selectedIds}
@@ -751,6 +756,7 @@ export function ConflictManagementScreen() {
compactDesktopLayout="pyramidFive" compactDesktopLayout="pyramidFive"
headerLockupSize={mdUp ? "L" : "M"} headerLockupSize={mdUp ? "L" : "M"}
/> />
) : null}
</div> </div>
</div> </div>
@@ -95,7 +95,8 @@ export function MembershipMethodsScreen() {
[mem.methods, selectedIds, state.customMethodCardMetaById], [mem.methods, selectedIds, state.customMethodCardMetaById],
); );
const { sampleCards, compactCardIds, methodById } = useMethodCardDeckOrdering( const { sampleCards, compactCardIds, methodById, recommendationsReady } =
useMethodCardDeckOrdering(
"membership", "membership",
mergedMethods, mergedMethods,
selectedIds, selectedIds,
@@ -727,7 +728,11 @@ export function MembershipMethodsScreen() {
justification="center" justification="center"
/> />
</div> </div>
<div className={CREATE_FLOW_CARD_STACK_AREA_MAX_CLASS}> <div
className={CREATE_FLOW_CARD_STACK_AREA_MAX_CLASS}
aria-busy={!recommendationsReady}
>
{recommendationsReady ? (
<CardStack <CardStack
cards={sampleCards} cards={sampleCards}
selectedIds={selectedIds} selectedIds={selectedIds}
@@ -744,6 +749,7 @@ export function MembershipMethodsScreen() {
compactDesktopLayout="pyramidFive" compactDesktopLayout="pyramidFive"
headerLockupSize={mdUp ? "L" : "M"} headerLockupSize={mdUp ? "L" : "M"}
/> />
) : null}
</div> </div>
</div> </div>
@@ -108,7 +108,8 @@ export function DecisionApproachesScreen() {
[da.methods, selectedIds, state.customMethodCardMetaById], [da.methods, selectedIds, state.customMethodCardMetaById],
); );
const { sampleCards, compactCardIds, methodById } = useMethodCardDeckOrdering( const { sampleCards, compactCardIds, methodById, recommendationsReady } =
useMethodCardDeckOrdering(
"decisionApproaches", "decisionApproaches",
mergedMethods, mergedMethods,
selectedIds, selectedIds,
@@ -761,7 +762,11 @@ export function DecisionApproachesScreen() {
</div> </div>
} }
> >
<div className="flex w-full min-w-0 flex-col items-stretch gap-6 py-0"> <div
className="flex w-full min-w-0 flex-col items-stretch gap-6 py-0"
aria-busy={!recommendationsReady}
>
{recommendationsReady ? (
<CardStack <CardStack
cards={sampleCards} cards={sampleCards}
selectedIds={selectedIds} selectedIds={selectedIds}
@@ -791,6 +796,7 @@ export function DecisionApproachesScreen() {
className="w-full" className="w-full"
headerLockupSize={mdUp ? "L" : "M"} headerLockupSize={mdUp ? "L" : "M"}
/> />
) : null}
</div> </div>
<Create <Create
+76
View File
@@ -0,0 +1,76 @@
import type { MethodFacetApiSectionId } from "./customRuleFacets";
export type FacetScoresBySlug = Record<string, number>;
const EMPTY_SCORES: FacetScoresBySlug = {};
const cache = new Map<string, FacetScoresBySlug>();
const inFlight = new Map<string, Promise<FacetScoresBySlug>>();
export function buildFacetRecommendationRequestKey(
section: MethodFacetApiSectionId,
queryString: string,
): string {
return `${section}?${queryString}`;
}
export function getCachedFacetScores(
requestKey: string,
): FacetScoresBySlug | undefined {
return cache.get(requestKey);
}
function parseScoresFromMethodsJson(json: {
methods?: { slug: string; matches?: { score?: number } }[];
}): FacetScoresBySlug {
const scoresBySlug: FacetScoresBySlug = {};
for (const m of json.methods ?? []) {
if (typeof m.slug === "string") {
scoresBySlug[m.slug] = m.matches?.score ?? 0;
}
}
return scoresBySlug;
}
async function fetchFacetScoresFromApi(
section: MethodFacetApiSectionId,
queryString: string,
): Promise<FacetScoresBySlug> {
const res = await fetch(
`/api/create-flow/methods?section=${section}&${queryString}`,
{ credentials: "include" },
);
if (!res.ok) throw new Error(`status ${res.status}`);
const json = (await res.json()) as {
methods?: { slug: string; matches?: { score?: number } }[];
};
return parseScoresFromMethodsJson(json);
}
/**
* Loads facet recommendation scores for one method deck. Results are cached
* and in-flight requests are deduped so prefetch + screen hooks share work.
*/
export function loadFacetScores(
section: MethodFacetApiSectionId,
queryString: string,
): Promise<FacetScoresBySlug> {
const requestKey = buildFacetRecommendationRequestKey(section, queryString);
const cached = cache.get(requestKey);
if (cached) return Promise.resolve(cached);
let pending = inFlight.get(requestKey);
if (!pending) {
pending = fetchFacetScoresFromApi(section, queryString)
.then((scores) => {
cache.set(requestKey, scores);
return scores;
})
.catch(() => EMPTY_SCORES)
.finally(() => {
inFlight.delete(requestKey);
});
inFlight.set(requestKey, pending);
}
return pending;
}
+7 -1
View File
@@ -2,7 +2,7 @@ import createMDX from "@next/mdx";
/* eslint-env node */ /* eslint-env node */
/** Keep viewBox so inline SVGR art can scale/center like `object-contain`. */ /** Keep viewBox and unique clip/mask IDs when multiple SVGR icons share a page. */
const svgrLoaderOptions = { const svgrLoaderOptions = {
svgoConfig: { svgoConfig: {
plugins: [ plugins: [
@@ -14,6 +14,12 @@ const svgrLoaderOptions = {
}, },
}, },
}, },
{
name: "prefixIds",
params: {
prefixClassNames: false,
},
},
], ],
}, },
}; };
@@ -0,0 +1,54 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
buildFacetRecommendationRequestKey,
getCachedFacetScores,
loadFacetScores,
} from "../../lib/create/facetRecommendationsLoad";
describe("loadFacetScores", () => {
beforeEach(() => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
methods: [
{ slug: "slack", matches: { score: 3 } },
{ slug: "email", matches: { score: 1 } },
],
}),
}),
);
});
afterEach(() => {
vi.unstubAllGlobals();
});
it("caches scores by section and facet query", async () => {
const queryString = "facet.size=small";
const scores = await loadFacetScores("communication", queryString);
expect(scores).toEqual({ slack: 3, email: 1 });
expect(
getCachedFacetScores(
buildFacetRecommendationRequestKey("communication", queryString),
),
).toEqual(scores);
expect(fetch).toHaveBeenCalledTimes(1);
await loadFacetScores("communication", queryString);
expect(fetch).toHaveBeenCalledTimes(1);
});
it("dedupes in-flight requests for the same key", async () => {
const queryString = "facet.size=small";
const [a, b] = await Promise.all([
loadFacetScores("membership", queryString),
loadFacetScores("membership", queryString),
]);
expect(a).toEqual(b);
expect(fetch).toHaveBeenCalledTimes(1);
});
});