Import the remade decks on stable ids and recommend one card per job, so a saved draft still loads after a chip is renamed.

The real-group profiles stay as a test, so a change that moves a pick fails.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
adilalloandCursor committed 2026-11-04 14:22:54 -07:00
1 parent 96ca15d8e3
commit bcbc6f30d9
89 files changed
+36555 -2828

No files matched your search

@@ -9,6 +9,12 @@ import {
loadFacetScores,
} from "../../../../lib/create/facetRecommendationsLoad";
import { useCreateFlow } from "../context/CreateFlowContext";
import { groupAnswersFromCreateFlowState } from "../../../../lib/create/groupAnswers";
import {
deckData,
recommendDeck,
} from "../../../../lib/create/deckRecommend";
import type { GroupAnswers } from "../../../../lib/create/deckTypes";
/**
* Card-deck section ids served by `/api/create-flow/methods` (CR-88 §9.2).
@@ -125,63 +131,46 @@ export function useFacetRecommendations(
}
/**
* Stable comparator for re-ranking a messages-file `methods[]` array. Higher
* `scoresBySlug[id]` first; ties fall back to authoring index, so a
* zero-facet user sees the original ordering verbatim.
* One card per job, then the display rule for the top `limit` cards.
* Cards marked every-rule carry no Recommended tag. With no facets, the
* first `limit` cards in deck order show and nothing is tagged.
*/
export function rankMethodsByScore<T extends { id: string }>(
methods: readonly T[],
scoresBySlug: Record<string, number>,
): T[] {
const indexById = new Map<string, number>();
methods.forEach((m, i) => indexById.set(m.id, i));
return [...methods].sort((a, b) => {
const sa = scoresBySlug[a.id] ?? 0;
const sb = scoresBySlug[b.id] ?? 0;
if (sa !== sb) return sb - sa;
return (indexById.get(a.id) ?? 0) - (indexById.get(b.id) ?? 0);
});
}
/**
* Picks (a) which method ids fill the compact card stack and (b) which of
* those should render with the "Recommended" tag. The messages JSON no
* longer carries a static `recommended` flag — both selections come
* entirely from facet scores (CR-88 §10).
*
* Behavior:
* - Facets selected & at least one method scored > 0 →
* `compactCardIds` = up to `limit` top-scored methods (1..limit cards;
* never padded with unrecommended fillers). All shown cards get the
* "Recommended" badge.
* - No facets selected, or every method scored 0 → `compactCardIds` =
* first `limit` in ranked/authoring order, `recommendedIds` empty (no
* badges shown — honest "no signal yet" fallback).
*
* `CardStack.view` is responsible for laying out variable-length compact
* arrays gracefully (uses `.map`/`.slice` and length-guarded indexing).
*/
export function deriveCompactCards<T extends { id: string }>(
rankedMethods: readonly T[],
scoresBySlug: Record<string, number>,
hasAnyFacets: boolean,
export function deriveCompactCards(
section: RecommendationSection,
answers: GroupAnswers,
limit: number,
): { compactCardIds: string[]; recommendedIds: Set<string> } {
const fallback = () => ({
compactCardIds: rankedMethods.slice(0, limit).map((m) => m.id),
recommendedIds: new Set<string>(),
});
if (!hasAnyFacets) return fallback();
const matched = rankedMethods.filter(
(m) => (scoresBySlug[m.id] ?? 0) > 0,
);
if (matched.length === 0) return fallback();
const top = matched.slice(0, limit);
): {
compactCardIds: string[];
recommendedIds: Set<string>;
orderedIds: string[];
} {
const recommendation = recommendDeck(deckData(section), answers, { limit });
const orderedIds = recommendation.fallback
? recommendation.seeAll
: [
...recommendation.shown.map((card) => card.id),
...recommendation.seeAll,
];
if (recommendation.fallback) {
return {
compactCardIds: orderedIds.slice(0, limit),
recommendedIds: new Set<string>(),
orderedIds,
};
}
return {
compactCardIds: top.map((m) => m.id),
recommendedIds: new Set(top.map((m) => m.id)),
compactCardIds: recommendation.shown.map((card) => card.id),
recommendedIds: new Set(
recommendation.recommended
.filter((card) => card.tag === "recommended")
.map((card) => card.id),
),
orderedIds,
};
}
export function groupAnswersForRecommendations(
state: Parameters<typeof groupAnswersFromCreateFlowState>[0],
): GroupAnswers {
return groupAnswersFromCreateFlowState(state);
}
@@ -4,68 +4,80 @@ import { useMemo } from "react";
import { mergeCompactCardIdsWithPinnedSelected } from "../../../../lib/create/methodCardDisplayOrder";
import {
deriveCompactCards,
rankMethodsByScore,
useFacetRecommendations,
groupAnswersForRecommendations,
type RecommendationSection,
} from "./useFacetRecommendations";
import { useCreateFlow } from "../context/CreateFlowContext";
type MethodEntry = { id: string; label: string; supportText: string };
function orderByIds<T extends { id: string }>(
methods: readonly T[],
orderedIds: readonly string[],
): T[] {
const byId = new Map(methods.map((method) => [method.id, method]));
const ordered = orderedIds.flatMap((id) => {
const method = byId.get(id);
return method ? [method] : [];
});
const seen = new Set(ordered.map((method) => method.id));
return [...ordered, ...methods.filter((method) => !seen.has(method.id))];
}
/**
* Applies score ranking and compact-slot rules. Expanded CardStack order stays
* the ranked catalog (selected cards keep their place). Compact slots still
* pin selected ids first so a pick outside the unpinned top-N remains visible
* when the stack is collapsed.
* Deck order and the compact stack come from the job rule. Current screens
* show five cards; a deck screen passes seven. Selected cards still pin
* into the compact stack.
*/
export function useMethodCardDeckOrdering(
section: RecommendationSection,
methods: readonly MethodEntry[],
selectedIds: readonly string[],
) {
const { scoresBySlug, hasAnyFacets, isReady } =
useFacetRecommendations(section);
const recommendationsReady = !hasAnyFacets || isReady;
const { state } = useCreateFlow();
const answers = useMemo(
() => groupAnswersForRecommendations(state),
[state],
);
const deck = useMemo(
() => deriveCompactCards(section, answers, 5),
[section, answers],
);
const rankedMethods = useMemo(
() => rankMethodsByScore(methods, scoresBySlug),
[methods, scoresBySlug],
() => orderByIds(methods, deck.orderedIds),
[methods, deck.orderedIds],
);
const selectionShowcaseActive = selectedIds.length > 0;
const displayMethods = rankedMethods;
const { compactCardIds: baseCompactCardIds, recommendedIds } = useMemo(
() =>
deriveCompactCards(
rankedMethods,
scoresBySlug,
hasAnyFacets,
/* limit */ 5,
),
[rankedMethods, scoresBySlug, hasAnyFacets],
);
const { recommendedIds } = deck;
const compactCardIds = useMemo(
() =>
mergeCompactCardIdsWithPinnedSelected(
rankedMethods.map((m) => m.id),
baseCompactCardIds,
rankedMethods.map((method) => method.id),
deck.compactCardIds,
selectedIds,
selectionShowcaseActive,
5,
),
[rankedMethods, baseCompactCardIds, selectedIds, selectionShowcaseActive],
[
rankedMethods,
deck.compactCardIds,
selectedIds,
selectionShowcaseActive,
],
);
const sampleCards = useMemo(
() =>
displayMethods.map((entry) => ({
rankedMethods.map((entry) => ({
id: entry.id,
label: entry.label,
supportText: entry.supportText,
recommended: recommendedIds.has(entry.id),
})),
[displayMethods, recommendedIds],
[rankedMethods, recommendedIds],
);
const methodById = useMemo(
@@ -75,11 +87,11 @@ export function useMethodCardDeckOrdering(
return {
rankedMethods,
displayMethods,
displayMethods: rankedMethods,
compactCardIds,
recommendedIds,
sampleCards,
methodById,
recommendationsReady,
recommendationsReady: true,
};
}
@@ -113,7 +113,10 @@ export function useTemplateReviewActions({
setTemplateReviewApplyError(loaded.message);
return;
}
const prefill = buildTemplateCustomizePrefill(loaded.template.body);
const prefill = buildTemplateCustomizePrefill(
loaded.template.body,
templateReviewSlug,
);
const pinPatch = methodSectionsPinsForHydratedSelections(prefill);
const hasCommunityName =
typeof state.title === "string" && state.title.trim().length > 0;
@@ -182,7 +185,10 @@ export function useTemplateReviewActions({
// (reset + update separately could leave selections undefined in Strict Mode).
replaceState((prev) => {
const base = stripCustomRuleSelectionFields(prev);
const customizePrefill = buildTemplateCustomizePrefill(doc);
const customizePrefill = buildTemplateCustomizePrefill(
doc,
templateReviewSlug ?? undefined,
);
const hasValuesSeed =
customizePrefill.selectedCoreValueIds !== undefined;