import type { RuleTemplateDto, TemplateFacetScoreDto } from "../create/fetchTemplates"; import { templateSummaryFromBody } from "../create/templateReviewMapping"; import type { GovernanceTemplateCatalogEntry } from "./governanceTemplateCatalog"; import { GOVERNANCE_TEMPLATE_CATALOG, getGovernanceTemplateCatalogEntry, governanceTemplateIconPath, } from "./governanceTemplateCatalog"; /** Matches TemplateReviewCard when slug is absent from the Figma catalog. */ export const TEMPLATE_GRID_FALLBACK_PRESENTATION = { iconPath: governanceTemplateIconPath("consensus"), backgroundColor: "bg-[var(--color-surface-invert-brand-teal)]", recommended: false, } as const; export type TemplateGridCardEntry = GovernanceTemplateCatalogEntry; function presentationForSlug(slug: string): Pick< GovernanceTemplateCatalogEntry, "iconPath" | "backgroundColor" | "recommended" > { const catalog = getGovernanceTemplateCatalogEntry(slug); if (catalog) { return { iconPath: catalog.iconPath, backgroundColor: catalog.backgroundColor, recommended: catalog.recommended === true, }; } return TEMPLATE_GRID_FALLBACK_PRESENTATION; } /** * One grid card: API copy + Figma icon/surface from catalog (or fallback). */ export function ruleTemplateToGridEntry(template: RuleTemplateDto): TemplateGridCardEntry { const pres = presentationForSlug(template.slug); const description = templateSummaryFromBody(template.description, template.body); return { slug: template.slug, title: template.title, description, iconPath: pres.iconPath, backgroundColor: pres.backgroundColor, recommended: pres.recommended, }; } /** * Max templates that show the “RECOMMENDED” tag when facet-ranked. Within the * **top score tier** only: we do not pad with lower-scoring templates (e.g. two * at score 4 and three at 3 → recommend the two 4s only), but if the top tier * exceeds this cap we still take the first `limit` in API order. */ export const TEMPLATE_GRID_COMPACT_RECOMMENDED_LIMIT = 5; /** * Among templates in **API rank order** (score desc) with `score > 0`, mark * only those in the **maximum-score tier** (no lower tiers), at most `limit` * (API order is the tie-break when many tie for first place). */ export function deriveRecommendedTemplateSlugs( templatesInRankOrder: ReadonlyArray<{ slug: string }>, scores: Record, limit: number, ): Set { if (limit <= 0) return new Set(); const matched = templatesInRankOrder.filter( (t) => (scores[t.slug]?.score ?? 0) > 0, ); if (matched.length === 0) return new Set(); let maxScore = 0; for (const t of matched) { const s = scores[t.slug]?.score ?? 0; if (s > maxScore) maxScore = s; } const topTier = matched.filter( (t) => (scores[t.slug]?.score ?? 0) === maxScore, ); return new Set(topTier.slice(0, limit).map((t) => t.slug)); } export type GridEntriesWithFacetScoresOptions = { /** Default {@link TEMPLATE_GRID_COMPACT_RECOMMENDED_LIMIT}. */ compactRecommendedLimit?: number; }; /** * After `GET /api/templates?facet.*` with `scores`, mark `recommended` only * for the top facet matches (see {@link deriveRecommendedTemplateSlugs}). */ export function gridEntriesWithFacetScores( templates: RuleTemplateDto[], scores: Record, options?: GridEntriesWithFacetScoresOptions, ): TemplateGridCardEntry[] { const cap = options?.compactRecommendedLimit ?? TEMPLATE_GRID_COMPACT_RECOMMENDED_LIMIT; const recommendedSlugs = deriveRecommendedTemplateSlugs( templates, scores, cap, ); return templates.map((t) => { const base = ruleTemplateToGridEntry(t); return { ...base, recommended: recommendedSlugs.has(t.slug), }; }); } const bySlug = (templates: RuleTemplateDto[]) => new Map(templates.map((t) => [t.slug, t] as const)); /** * Home row: prefer API row per slug; if missing, use static Figma catalog entry. */ export function gridEntriesForSlugOrderWithCatalogFallback( templates: RuleTemplateDto[], slugOrder: readonly string[], ): TemplateGridCardEntry[] { const map = bySlug(templates); const out: TemplateGridCardEntry[] = []; for (const slug of slugOrder) { const t = map.get(slug); if (t) { out.push(ruleTemplateToGridEntry(t)); continue; } const cat = getGovernanceTemplateCatalogEntry(slug); if (cat) out.push(cat); } return out; } /** * Full templates index: `featured` first, then `sortOrder`, then title. */ export function gridEntriesForFullCatalog(templates: RuleTemplateDto[]): TemplateGridCardEntry[] { const withSort = [...templates].sort((a, b) => { if (a.featured !== b.featured) return a.featured ? -1 : 1; if (a.sortOrder !== b.sortOrder) return a.sortOrder - b.sortOrder; return a.title.localeCompare(b.title); }); return withSort.map(ruleTemplateToGridEntry); } /** * Marketing `/templates`: use API order when rows exist; otherwise static catalog. */ export function gridEntriesForFullCatalogWithFallback( templates: RuleTemplateDto[], ): TemplateGridCardEntry[] { if (templates.length === 0) { return [...GOVERNANCE_TEMPLATE_CATALOG]; } return gridEntriesForFullCatalog(templates); }