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:
1 parent
96ca15d8e3
commit
bcbc6f30d9
89 files changed
+36555
-2828
No files matched your search
@@ -3,8 +3,10 @@ import type {
|
||||
CreateFlowState,
|
||||
} from "../../app/(app)/create/types";
|
||||
import coreValuesMessages from "../../messages/en/create/customRule/coreValues.json";
|
||||
import templateChipsFile from "../../data/templates/templateChips.json";
|
||||
import { assignTemplateMethodSlugsToPrefill } from "./customRuleFacets";
|
||||
import { methodSlugFromTitle } from "./methodSlugFromTitle";
|
||||
import type { DeckId } from "./deckTypes";
|
||||
|
||||
type TemplateEntry = { title: unknown };
|
||||
type TemplateSection = { categoryName: unknown; entries: unknown };
|
||||
@@ -33,15 +35,19 @@ function normaliseCategoryKey(name: string): string {
|
||||
return name.toLowerCase().replace(/[^a-z]+/g, "");
|
||||
}
|
||||
|
||||
/** Preset core-value labels with the chip id (1-based preset index as string) the select screen expects. */
|
||||
/** Preset core values keyed by the stable id in `coreValues.json`. */
|
||||
type CorePresetRow = { id: string; label: string };
|
||||
const CORE_VALUE_PRESETS: readonly CorePresetRow[] = (() => {
|
||||
const raw = (coreValuesMessages as { values: unknown }).values;
|
||||
if (!Array.isArray(raw)) return [];
|
||||
return raw.map((v, i) => ({
|
||||
id: String(i + 1),
|
||||
label: typeof v === "string" ? v : (v as { label: string }).label,
|
||||
}));
|
||||
const out: CorePresetRow[] = [];
|
||||
for (const value of raw) {
|
||||
if (!value || typeof value !== "object") continue;
|
||||
const row = value as { id?: unknown; label?: unknown };
|
||||
if (typeof row.id !== "string" || typeof row.label !== "string") continue;
|
||||
out.push({ id: row.id, label: row.label });
|
||||
}
|
||||
return out;
|
||||
})();
|
||||
|
||||
function buildCoreValuePrefill(
|
||||
@@ -95,9 +101,37 @@ function buildCoreValuePrefill(
|
||||
*
|
||||
* Returns an empty object for malformed bodies (no sections array).
|
||||
*/
|
||||
type TemplateChipSet = Record<DeckId | "coreValues", string[]>;
|
||||
|
||||
function prefillFromTemplateChips(slug: string): Partial<CreateFlowState> | null {
|
||||
const templates = (
|
||||
templateChipsFile as { templates: Record<string, TemplateChipSet> }
|
||||
).templates;
|
||||
const chips = templates[slug];
|
||||
if (!chips) return null;
|
||||
const selected = new Set(chips.coreValues);
|
||||
return {
|
||||
selectedCoreValueIds: [...chips.coreValues],
|
||||
coreValuesChipsSnapshot: CORE_VALUE_PRESETS.map((preset) => ({
|
||||
id: preset.id,
|
||||
label: preset.label,
|
||||
state: selected.has(preset.id) ? "selected" : "unselected",
|
||||
})),
|
||||
selectedDecisionApproachIds: [...chips.decisionApproaches],
|
||||
selectedMembershipMethodIds: [...chips.membership],
|
||||
selectedConflictManagementIds: [...chips.conflictManagement],
|
||||
selectedCommunicationMethodIds: [...chips.communication],
|
||||
};
|
||||
}
|
||||
|
||||
export function buildTemplateCustomizePrefill(
|
||||
body: unknown,
|
||||
templateSlug?: string,
|
||||
): Partial<CreateFlowState> {
|
||||
if (templateSlug) {
|
||||
const fromIds = prefillFromTemplateChips(templateSlug);
|
||||
if (fromIds) return fromIds;
|
||||
}
|
||||
if (!body || typeof body !== "object") return {};
|
||||
const sections = (body as { sections?: unknown }).sections;
|
||||
if (!Array.isArray(sections)) return {};
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import facetGroups from "../../data/create/customRule/_facetGroups.json";
|
||||
import type { CreateFlowState } from "../../app/(app)/create/types";
|
||||
import {
|
||||
resolveMaturityChipId,
|
||||
resolveOrgChipId,
|
||||
resolveScaleChipId,
|
||||
resolveSizeChipId,
|
||||
} from "./communityChipIds";
|
||||
|
||||
const FACET_GROUPS = ["size", "orgType", "scale", "maturity"] as const;
|
||||
type FacetGroupId = (typeof FACET_GROUPS)[number];
|
||||
@@ -32,6 +38,13 @@ const STATE_KEY_BY_GROUP: Record<FacetGroupId, keyof CreateFlowState> = {
|
||||
maturity: "selectedMaturityIds",
|
||||
};
|
||||
|
||||
const RESOLVE_BY_GROUP: Record<FacetGroupId, (id: string) => string> = {
|
||||
size: resolveSizeChipId,
|
||||
orgType: resolveOrgChipId,
|
||||
scale: resolveScaleChipId,
|
||||
maturity: resolveMaturityChipId,
|
||||
};
|
||||
|
||||
function readChipIds(
|
||||
state: CreateFlowState,
|
||||
group: FacetGroupId,
|
||||
@@ -49,11 +62,14 @@ export function buildFacetQueryString(state: CreateFlowState): string {
|
||||
const params = new URLSearchParams();
|
||||
for (const group of FACET_GROUPS) {
|
||||
const valuesById = CHIP_TO_VALUE_BY_GROUP[group];
|
||||
const resolve = RESOLVE_BY_GROUP[group];
|
||||
const seen = new Set<string>();
|
||||
for (const chipId of readChipIds(state, group)) {
|
||||
const valueId = valuesById[chipId];
|
||||
if (valueId) {
|
||||
params.append(`facet.${group}`, valueId);
|
||||
}
|
||||
const valueId = valuesById[resolve(chipId)] ?? valuesById[chipId];
|
||||
if (valueId === "enterprise") continue;
|
||||
if (!valueId || seen.has(valueId)) continue;
|
||||
seen.add(valueId);
|
||||
params.append(`facet.${group}`, valueId);
|
||||
}
|
||||
}
|
||||
return params.toString();
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
replaceMethodSectionsWithMethodSelections,
|
||||
withoutUnpublishedDecisionConsensus,
|
||||
} from "./ruleSectionsFromMethodSelections";
|
||||
import { LAUNCH_SUPERMAJORITY_SHARE } from "./sharedTermsSection";
|
||||
import { templateCategoryToGroupKey } from "./templateReviewMapping";
|
||||
|
||||
export { isDocumentEntry } from "./documentEntryGuards";
|
||||
@@ -167,7 +168,11 @@ export function buildPublishPayload(
|
||||
);
|
||||
}
|
||||
|
||||
const document: Record<string, unknown> = { sections, coreValues };
|
||||
const document: Record<string, unknown> = {
|
||||
sections,
|
||||
coreValues,
|
||||
supermajorityShare: LAUNCH_SUPERMAJORITY_SHARE,
|
||||
};
|
||||
if (hasAnyMethodSelection(methodSelections)) {
|
||||
document.methodSelections = methodSelections;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import type { CommunityStructureChipSnapshotRow } from "../../app/(app)/create/types";
|
||||
import type { Stage } from "./deckTypes";
|
||||
|
||||
/**
|
||||
* Stable chip ids for Create Community. Old drafts stored a 1-based position.
|
||||
* Enterprise and Larger organization both load as Established.
|
||||
*/
|
||||
const SIZE_BY_POSITION = [
|
||||
"oneMember",
|
||||
"twoToFive",
|
||||
"sixToTwelve",
|
||||
"thirteenToOneHundred",
|
||||
"oneHundredToOneHundredK",
|
||||
] as const;
|
||||
|
||||
const ORG_BY_POSITION = [
|
||||
"workersCoop",
|
||||
"mutualAid",
|
||||
"openSource",
|
||||
"nonprofit",
|
||||
"forProfit",
|
||||
"dao",
|
||||
] as const;
|
||||
|
||||
const SCALE_BY_POSITION = ["local", "regional", "national", "global"] as const;
|
||||
|
||||
const MATURITY_BY_POSITION = [
|
||||
"earlyStage",
|
||||
"growthStage",
|
||||
"established",
|
||||
"established",
|
||||
] as const;
|
||||
|
||||
const MATURITY_LABEL_ALIASES: Record<string, Stage> = {
|
||||
"Early stage": "earlyStage",
|
||||
"Growth stage": "growthStage",
|
||||
Established: "established",
|
||||
Enterprise: "established",
|
||||
"Larger organization": "established",
|
||||
};
|
||||
|
||||
const ORG_LABEL_ALIASES: Record<string, string> = {
|
||||
"Worker cooperative": "workersCoop",
|
||||
"Worker co-op": "workersCoop",
|
||||
"Mutual aid": "mutualAid",
|
||||
"Open source project": "openSource",
|
||||
Nonprofit: "nonprofit",
|
||||
"For profit business": "forProfit",
|
||||
DAO: "dao",
|
||||
};
|
||||
|
||||
function fromPosition(
|
||||
id: string,
|
||||
positions: readonly string[],
|
||||
): string | null {
|
||||
if (!/^\d+$/.test(id)) return null;
|
||||
const index = Number(id) - 1;
|
||||
if (index < 0 || index >= positions.length) return null;
|
||||
return positions[index] ?? null;
|
||||
}
|
||||
|
||||
export function resolveSizeChipId(id: string): string {
|
||||
return fromPosition(id, SIZE_BY_POSITION) ?? id;
|
||||
}
|
||||
|
||||
export function resolveOrgChipId(id: string, label?: string): string {
|
||||
if (label && ORG_LABEL_ALIASES[label]) return ORG_LABEL_ALIASES[label];
|
||||
return fromPosition(id, ORG_BY_POSITION) ?? id;
|
||||
}
|
||||
|
||||
export function resolveScaleChipId(id: string): string {
|
||||
return fromPosition(id, SCALE_BY_POSITION) ?? id;
|
||||
}
|
||||
|
||||
export function resolveMaturityChipId(id: string, label?: string): string {
|
||||
if (label && MATURITY_LABEL_ALIASES[label]) return MATURITY_LABEL_ALIASES[label];
|
||||
if (id === "enterprise" || id === "larger-organization") return "established";
|
||||
return fromPosition(id, MATURITY_BY_POSITION) ?? id;
|
||||
}
|
||||
|
||||
export function resolveChipIdList(
|
||||
ids: readonly string[] | undefined,
|
||||
resolve: (id: string) => string,
|
||||
): string[] | undefined {
|
||||
if (!ids) return undefined;
|
||||
const out: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const id of ids) {
|
||||
const next = resolve(id);
|
||||
if (!next || seen.has(next)) continue;
|
||||
seen.add(next);
|
||||
out.push(next);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
type PresetChip = { id: string; label: string };
|
||||
|
||||
/**
|
||||
* Rewrite a saved chip snapshot onto the current preset ids and labels.
|
||||
* Custom rows stay. Several maturity answers stay, as distinct ids.
|
||||
*/
|
||||
export function migrateChipSnapshot(
|
||||
rows: CommunityStructureChipSnapshotRow[] | undefined,
|
||||
presets: readonly PresetChip[],
|
||||
resolve: (id: string, label?: string) => string,
|
||||
): CommunityStructureChipSnapshotRow[] | undefined {
|
||||
if (!rows || rows.length === 0) return rows;
|
||||
const presetIds = new Set(presets.map((p) => p.id));
|
||||
const selected = new Set<string>();
|
||||
const customs: CommunityStructureChipSnapshotRow[] = [];
|
||||
for (const row of rows) {
|
||||
const nextId = resolve(row.id, row.label);
|
||||
if (presetIds.has(nextId)) {
|
||||
if (row.state === "selected") selected.add(nextId);
|
||||
continue;
|
||||
}
|
||||
customs.push({ ...row, id: nextId });
|
||||
}
|
||||
return [
|
||||
...presets.map((preset) => ({
|
||||
id: preset.id,
|
||||
label: preset.label,
|
||||
state: selected.has(preset.id) ? "selected" : "unselected",
|
||||
})),
|
||||
...customs,
|
||||
];
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
} from "./duplicateMethodCardModalDraft";
|
||||
import { moveFacetSelectionIdToFront } from "./methodCardSelectionOrder";
|
||||
|
||||
export const MAX_SELECTED_CORE_VALUES = 5;
|
||||
export const MAX_SELECTED_CORE_VALUES = 6;
|
||||
|
||||
/** Remove a chip from snapshot, selection ids, and per-chip detail overrides. */
|
||||
export function removeCoreValueChipFromDraft(
|
||||
|
||||
@@ -3,11 +3,16 @@ import type {
|
||||
CreateFlowState,
|
||||
} from "../../app/(app)/create/types";
|
||||
|
||||
type CoreValuePreset = { label: string; meaning: string; signals: string };
|
||||
type CoreValuePreset = {
|
||||
id: string;
|
||||
label: string;
|
||||
meaning: string;
|
||||
signals: string;
|
||||
};
|
||||
|
||||
function chipRowsFromPresets(presets: readonly CoreValuePreset[]): ChipOption[] {
|
||||
return presets.map((row, i) => ({
|
||||
id: String(i + 1),
|
||||
return presets.map((row) => ({
|
||||
id: row.id,
|
||||
label: row.label,
|
||||
state: "unselected" as const,
|
||||
}));
|
||||
|
||||
@@ -0,0 +1,283 @@
|
||||
/**
|
||||
* Reference engine for RECOMMENDED and the seven, as agreed on 24 and 27 October 2026.
|
||||
*
|
||||
* Pure functions with no I/O, written to be ported into
|
||||
* useFacetRecommendations.ts (deriveCompactCards) and methodRecommendations.ts.
|
||||
* The README in this package states the same rule in words.
|
||||
*/
|
||||
import communicationFacets from "../../data/create/customRule/communication.json";
|
||||
import conflictFacets from "../../data/create/customRule/conflictManagement.json";
|
||||
import decisionFacets from "../../data/create/customRule/decisionApproaches.json";
|
||||
import jobsFile from "../../data/create/customRule/_jobs.json";
|
||||
import membershipFacets from "../../data/create/customRule/membership.json";
|
||||
import type {
|
||||
CardEngineEntry,
|
||||
DeckEngine,
|
||||
DeckId,
|
||||
DeckRecommendation,
|
||||
FacetFile,
|
||||
GroupAnswers,
|
||||
JobsFile,
|
||||
Kind,
|
||||
MatchedFacet,
|
||||
RankTier,
|
||||
RecommendedCard,
|
||||
RecommendationReason,
|
||||
SizeBand,
|
||||
TraitKey,
|
||||
} from "./deckTypes";
|
||||
|
||||
export interface DeckData {
|
||||
deck: DeckId;
|
||||
facets: FacetFile;
|
||||
engine: DeckEngine;
|
||||
config: Pick<
|
||||
JobsFile,
|
||||
"displayLimit" | "maxAddedInSeven" | "tierOrder" | "traitKeys" | "sizeAnswers"
|
||||
>;
|
||||
}
|
||||
|
||||
export interface RecommendOptions {
|
||||
/** How many recommended cards show before See all. A screen that shows five passes 5. */
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
interface Evaluated {
|
||||
id: string;
|
||||
entry: CardEngineEntry;
|
||||
eligible: boolean;
|
||||
matched: MatchedFacet[];
|
||||
fitsAnswer?: TraitKey;
|
||||
openedBy?: TraitKey;
|
||||
score: number;
|
||||
otherKindHits: number;
|
||||
}
|
||||
|
||||
/** Normalizes setup answers. A group with other kinds and no main kind gets the first as main. */
|
||||
export function normalizeAnswers(answers: GroupAnswers): Required<GroupAnswers> {
|
||||
const others = [...(answers.otherKinds ?? [])];
|
||||
const mainKind = answers.mainKind ?? others.shift() ?? null;
|
||||
return {
|
||||
size: answers.size ?? null,
|
||||
mainKind,
|
||||
otherKinds: others.filter((k) => k !== mainKind),
|
||||
scale: [...new Set(answers.scale ?? [])],
|
||||
stage: answers.stage ?? null,
|
||||
traits: answers.traits ?? {},
|
||||
};
|
||||
}
|
||||
|
||||
function sizeBandOf(size: string | null, data: DeckData): SizeBand | null {
|
||||
if (!size) return null;
|
||||
const band = data.config.sizeAnswers[size];
|
||||
if (!band) throw new Error(`Unknown size answer ${size}`);
|
||||
return band;
|
||||
}
|
||||
|
||||
function evaluate(
|
||||
id: string,
|
||||
data: DeckData,
|
||||
a: Required<GroupAnswers>,
|
||||
band: SizeBand | null,
|
||||
ticked: Set<TraitKey>,
|
||||
): Evaluated {
|
||||
const f = data.facets[id];
|
||||
const entry = data.engine.cards[id];
|
||||
if (!f || !entry) throw new Error(`Card ${id} is missing from the facet or jobs file`);
|
||||
const traits = entry.traits ?? {};
|
||||
const fitsSize = band ? f.size[band] : true;
|
||||
const fitsKind = a.mainKind ? f.orgType[a.mainKind] : true;
|
||||
const openedBy = (traits.opens ?? []).find((k) => ticked.has(k));
|
||||
const isBeside = (traits.beside ?? []).length > 0;
|
||||
// Each facet group counts once. Scale earns its point only if the card fits
|
||||
// every scale the group ticked (27 Oct, call 2).
|
||||
const fitsScale = a.scale.length > 0 && a.scale.every((s) => f.scale[s]);
|
||||
const fitsStage = a.stage !== null && f.maturity[a.stage];
|
||||
const fitsAnswer = (traits.fits ?? []).find((k) => ticked.has(k));
|
||||
const matched: MatchedFacet[] = [];
|
||||
if (band && fitsSize) matched.push("size");
|
||||
if (a.mainKind && fitsKind) matched.push("kind");
|
||||
if (fitsScale) matched.push("scale");
|
||||
if (fitsStage) matched.push("stage");
|
||||
return {
|
||||
id,
|
||||
entry,
|
||||
// Size and the main kind decide eligibility, or a ticked answer opens the card.
|
||||
// A card an answer adds never competes for a job.
|
||||
eligible: !isBeside && fitsSize && (fitsKind || openedBy !== undefined),
|
||||
matched,
|
||||
fitsAnswer,
|
||||
openedBy,
|
||||
// Inside a job, scale, stage and the one point from answers decide (27 Oct, call 1).
|
||||
score: (fitsScale ? 1 : 0) + (fitsStage ? 1 : 0) + (fitsAnswer ? 1 : 0),
|
||||
otherKindHits: a.otherKinds.filter((k: Kind) => f.orgType[k]).length,
|
||||
};
|
||||
}
|
||||
|
||||
/** Best first. Score, then other kinds, then row order inside the job. */
|
||||
function compareCandidates(x: Evaluated, y: Evaluated): number {
|
||||
return (
|
||||
y.score - x.score ||
|
||||
y.otherKindHits - x.otherKindHits ||
|
||||
x.entry.priority - y.entry.priority
|
||||
);
|
||||
}
|
||||
|
||||
/** What separated the winner from the runner-up. */
|
||||
function decidedBy(c: Evaluated[]): RecommendedCard["decidedBy"] {
|
||||
if (c.length < 2) return "only";
|
||||
if (c[0].score !== c[1].score) return "score";
|
||||
if (c[0].otherKindHits !== c[1].otherKindHits) return "otherKinds";
|
||||
return "rowOrder";
|
||||
}
|
||||
|
||||
export function recommendDeck(
|
||||
data: DeckData,
|
||||
answers: GroupAnswers,
|
||||
options: RecommendOptions = {},
|
||||
): DeckRecommendation {
|
||||
const limit = options.limit ?? data.config.displayLimit;
|
||||
const a = normalizeAnswers(answers);
|
||||
const band = sizeBandOf(a.size, data);
|
||||
const ticked = new Set<TraitKey>(data.config.traitKeys.filter((k) => a.traits[k] === true));
|
||||
const jobs = data.engine.jobs;
|
||||
const jobIndex = new Map(jobs.map((j, i) => [j.id, i]));
|
||||
const allInDeckOrder = deckOrder(data);
|
||||
|
||||
const anyFacet = band !== null || a.mainKind !== null || a.scale.length > 0 || a.stage !== null;
|
||||
const fallback = (): DeckRecommendation => ({
|
||||
deck: data.deck,
|
||||
fallback: true,
|
||||
recommended: [],
|
||||
shown: [],
|
||||
waiting: [],
|
||||
seeAll: allInDeckOrder,
|
||||
});
|
||||
if (!anyFacet) return fallback();
|
||||
|
||||
const cardIds = Object.keys(data.engine.cards);
|
||||
const evaluated = new Map(cardIds.map((id) => [id, evaluate(id, data, a, band, ticked)]));
|
||||
const tierRank = (t: RankTier) => data.config.tierOrder.indexOf(t);
|
||||
const recommended: (RecommendedCard & { sortKey: number[]; showKey: number[] })[] = [];
|
||||
|
||||
// One card per job that has an eligible card. The runner-up is its alternative.
|
||||
for (const job of jobs) {
|
||||
if (job.displayTier === "none") continue;
|
||||
const candidates = [...evaluated.values()]
|
||||
.filter((e) => e.entry.job === job.id && e.eligible)
|
||||
.sort(compareCandidates);
|
||||
const winner = candidates[0];
|
||||
if (!winner) continue;
|
||||
const reason: RecommendationReason =
|
||||
winner.openedBy && !winner.matched.includes("kind")
|
||||
? { kind: "opened", matched: winner.matched, answer: winner.openedBy }
|
||||
: { kind: "fit", matched: winner.matched, ...(winner.fitsAnswer ? { answer: winner.fitsAnswer } : {}) };
|
||||
recommended.push({
|
||||
id: winner.id,
|
||||
job: job.id,
|
||||
showsWith: job.id,
|
||||
tier: job.displayTier as RankTier,
|
||||
tag: winner.entry.everyRuleHas ? "everyRuleHas" : "recommended",
|
||||
reason,
|
||||
...(candidates[1] ? { alternative: candidates[1].id } : {}),
|
||||
score: winner.score,
|
||||
decidedBy: decidedBy(candidates),
|
||||
sortKey: [tierRank(job.displayTier as RankTier), job.rank ?? 0, 0],
|
||||
showKey: [jobIndex.get(job.id)!, 0, 0],
|
||||
});
|
||||
}
|
||||
|
||||
// Cards a ticked answer adds beside a job, whatever the group's size or kind.
|
||||
const added = data.engine.addedOrder.filter((id) => {
|
||||
const e = evaluated.get(id)!;
|
||||
return (e.entry.traits?.beside ?? []).some((k) => ticked.has(k));
|
||||
});
|
||||
added.forEach((id, i) => {
|
||||
const e = evaluated.get(id)!;
|
||||
const besideJob = e.entry.traits!.besideJob!;
|
||||
const answer = e.entry.traits!.beside!.find((k) => ticked.has(k))!;
|
||||
const inSevenSlot = i < data.config.maxAddedInSeven;
|
||||
recommended.push({
|
||||
id,
|
||||
job: e.entry.job,
|
||||
showsWith: besideJob,
|
||||
tier: "answers",
|
||||
tag: e.entry.everyRuleHas ? "everyRuleHas" : "recommended",
|
||||
reason: { kind: "added", answer },
|
||||
// The first added cards rank right after the safety jobs, any more rank last.
|
||||
sortKey: inSevenSlot ? [tierRank("answers"), 0, i] : [data.config.tierOrder.length, 0, i],
|
||||
showKey: [jobIndex.get(besideJob)!, 1, i],
|
||||
});
|
||||
});
|
||||
|
||||
if (recommended.length === 0) return fallback();
|
||||
|
||||
const compareKeys = (x: number[], y: number[]) => {
|
||||
for (let i = 0; i < x.length; i++) if (x[i] !== y[i]) return x[i] - y[i];
|
||||
return 0;
|
||||
};
|
||||
// Rank decides which cards show. Deck order decides where they show.
|
||||
const ranked = [...recommended].sort((x, y) => compareKeys(x.sortKey, y.sortKey));
|
||||
const shownIds = new Set(ranked.slice(0, limit).map((r) => r.id));
|
||||
const strip = ({ sortKey: _s, showKey: _w, ...card }: (typeof recommended)[number]): RecommendedCard => card;
|
||||
const inShowOrder = [...recommended].sort((x, y) => compareKeys(x.showKey, y.showKey));
|
||||
const shown = inShowOrder.filter((r) => shownIds.has(r.id)).map(strip);
|
||||
const waiting = inShowOrder.filter((r) => !shownIds.has(r.id)).map(strip);
|
||||
const recommendedIds = new Set(recommended.map((r) => r.id));
|
||||
|
||||
return {
|
||||
deck: data.deck,
|
||||
fallback: false,
|
||||
recommended: ranked.map(strip),
|
||||
shown,
|
||||
waiting,
|
||||
seeAll: [...waiting.map((r) => r.id), ...allInDeckOrder.filter((id) => !recommendedIds.has(id))],
|
||||
};
|
||||
}
|
||||
|
||||
/** Every card in deck order, job by job, then by priority inside each job. */
|
||||
export function deckOrder(data: DeckData): string[] {
|
||||
const jobIndex = new Map(data.engine.jobs.map((j, i) => [j.id, i]));
|
||||
const key = (id: string) => {
|
||||
const e = data.engine.cards[id];
|
||||
return (jobIndex.get(e.job) ?? data.engine.jobs.length) * 1000 + e.priority;
|
||||
};
|
||||
return Object.keys(data.engine.cards).sort((x, y) => key(x) - key(y));
|
||||
}
|
||||
|
||||
/**
|
||||
* The template path (27 Oct). A group that starts from a template answers no setup
|
||||
* question and gets no recommendations. Each deck shows the template's cards first,
|
||||
* in the template's order, with no tag and no seven-card cut, then the rest of the
|
||||
* deck in deck order. templateCardIds come from templateChips.json.
|
||||
*/
|
||||
const jobs = jobsFile as JobsFile;
|
||||
const FACETS: Record<DeckId, FacetFile> = {
|
||||
decisionApproaches: decisionFacets as FacetFile,
|
||||
membership: membershipFacets as FacetFile,
|
||||
conflictManagement: conflictFacets as FacetFile,
|
||||
communication: communicationFacets as FacetFile,
|
||||
};
|
||||
|
||||
/** One deck's facets, jobs and display config, ready for {@link recommendDeck}. */
|
||||
export function deckData(deck: DeckId): DeckData {
|
||||
return {
|
||||
deck,
|
||||
facets: FACETS[deck],
|
||||
engine: jobs.decks[deck],
|
||||
config: {
|
||||
displayLimit: jobs.displayLimit,
|
||||
maxAddedInSeven: jobs.maxAddedInSeven,
|
||||
tierOrder: jobs.tierOrder,
|
||||
traitKeys: jobs.traitKeys,
|
||||
sizeAnswers: jobs.sizeAnswers,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function templateDeck(data: DeckData, templateCardIds: string[]): { deck: DeckId; first: string[]; rest: string[] } {
|
||||
const live = new Set(Object.keys(data.engine.cards));
|
||||
const first = [...new Set(templateCardIds)].filter((id) => live.has(id));
|
||||
const chosen = new Set(first);
|
||||
return { deck: data.deck, first, rest: deckOrder(data).filter((id) => !chosen.has(id)) };
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
/** Deck engine types. Shape matches the 2 Nov 2026 deck package. */
|
||||
|
||||
export const DECK_IDS = [
|
||||
"decisionApproaches",
|
||||
"membership",
|
||||
"conflictManagement",
|
||||
"communication",
|
||||
] as const;
|
||||
export type DeckId = (typeof DECK_IDS)[number];
|
||||
|
||||
export const SIZE_BANDS = [
|
||||
"oneMember",
|
||||
"twoToFive",
|
||||
"sixToTwelve",
|
||||
"thirteenToOneHundred",
|
||||
"oneHundredToOneHundredK",
|
||||
] as const;
|
||||
export type SizeBand = (typeof SIZE_BANDS)[number];
|
||||
|
||||
export const KINDS = [
|
||||
"workersCoop",
|
||||
"mutualAid",
|
||||
"nonprofit",
|
||||
"forProfit",
|
||||
"openSource",
|
||||
"dao",
|
||||
] as const;
|
||||
export type Kind = (typeof KINDS)[number];
|
||||
|
||||
export const SCALES = ["local", "regional", "national", "global"] as const;
|
||||
export type Scale = (typeof SCALES)[number];
|
||||
|
||||
export const STAGES = [
|
||||
"earlyStage",
|
||||
"growthStage",
|
||||
"established",
|
||||
"enterprise",
|
||||
] as const;
|
||||
export type Stage = (typeof STAGES)[number];
|
||||
|
||||
export const TRAIT_KEYS = [
|
||||
"network",
|
||||
"nonvoters",
|
||||
"employer",
|
||||
"sponsor",
|
||||
"records",
|
||||
"risky",
|
||||
] as const;
|
||||
export type TraitKey = (typeof TRAIT_KEYS)[number];
|
||||
|
||||
export const DISPLAY_TIERS = ["frame", "safety", "fitted", "floor", "none"] as const;
|
||||
export type DisplayTier = (typeof DISPLAY_TIERS)[number];
|
||||
export type RankTier = Exclude<DisplayTier, "none"> | "answers";
|
||||
|
||||
export interface CardFacets {
|
||||
size: Record<SizeBand, boolean>;
|
||||
orgType: Record<Kind, boolean>;
|
||||
scale: Record<Scale, boolean>;
|
||||
maturity: Record<Stage, boolean>;
|
||||
}
|
||||
export type FacetFile = Record<string, CardFacets>;
|
||||
|
||||
export interface CardTraits {
|
||||
beside?: TraitKey[];
|
||||
besideJob?: string;
|
||||
fits?: TraitKey[];
|
||||
opens?: TraitKey[];
|
||||
}
|
||||
|
||||
export interface CardEngineEntry {
|
||||
job: string;
|
||||
priority: number;
|
||||
everyRuleHas?: boolean;
|
||||
traits?: CardTraits;
|
||||
}
|
||||
|
||||
export interface JobDefinition {
|
||||
id: string;
|
||||
label: string;
|
||||
displayTier: DisplayTier;
|
||||
rank?: number;
|
||||
why: string;
|
||||
}
|
||||
|
||||
export interface DeckEngine {
|
||||
jobs: JobDefinition[];
|
||||
addedOrder: string[];
|
||||
cards: Record<string, CardEngineEntry>;
|
||||
}
|
||||
|
||||
export interface JobsFile {
|
||||
about: string;
|
||||
version: string;
|
||||
displayLimit: number;
|
||||
maxAddedInSeven: number;
|
||||
tierOrder: RankTier[];
|
||||
traitKeys: TraitKey[];
|
||||
sizeAnswers: Record<string, SizeBand>;
|
||||
decks: Record<DeckId, DeckEngine>;
|
||||
}
|
||||
|
||||
export interface GroupAnswers {
|
||||
size?: string | null;
|
||||
mainKind?: Kind | null;
|
||||
otherKinds?: Kind[];
|
||||
scale?: Scale[];
|
||||
stage?: Stage | null;
|
||||
traits?: Partial<Record<TraitKey, boolean>>;
|
||||
}
|
||||
|
||||
export type MatchedFacet = "size" | "kind" | "scale" | "stage";
|
||||
|
||||
export type RecommendationReason =
|
||||
| { kind: "fit"; matched: MatchedFacet[]; answer?: TraitKey }
|
||||
| { kind: "opened"; matched: MatchedFacet[]; answer: TraitKey }
|
||||
| { kind: "added"; answer: TraitKey };
|
||||
|
||||
export interface RecommendedCard {
|
||||
id: string;
|
||||
job: string;
|
||||
showsWith: string;
|
||||
tier: RankTier;
|
||||
tag: "recommended" | "everyRuleHas";
|
||||
reason: RecommendationReason;
|
||||
alternative?: string;
|
||||
score?: number;
|
||||
decidedBy?: "only" | "score" | "otherKinds" | "rowOrder";
|
||||
}
|
||||
|
||||
export interface DeckRecommendation {
|
||||
deck: DeckId;
|
||||
fallback: boolean;
|
||||
recommended: RecommendedCard[];
|
||||
shown: RecommendedCard[];
|
||||
waiting: RecommendedCard[];
|
||||
seeAll: string[];
|
||||
}
|
||||
@@ -113,9 +113,6 @@ export function emptyMembershipDetails(): MembershipMethodDetailEntry {
|
||||
return { eligibility: "", joiningProcess: "", expectations: "" };
|
||||
}
|
||||
|
||||
/** Default consensus level used when a **catalog** preset omits a value. */
|
||||
export const DECISION_CONSENSUS_LEVEL_DEFAULT = 75;
|
||||
|
||||
export function decisionApproachPresetFor(
|
||||
id: string,
|
||||
): DecisionApproachDetailEntry {
|
||||
@@ -128,13 +125,8 @@ export function decisionApproachPresetFor(
|
||||
stepByStepInstructions: asString(s.stepByStepInstructions),
|
||||
objectionsDeadlocks: asString(s.objectionsDeadlocks),
|
||||
};
|
||||
if (method) {
|
||||
entry.consensusLevel = asNumberClamped(
|
||||
s.consensusLevel,
|
||||
0,
|
||||
100,
|
||||
DECISION_CONSENSUS_LEVEL_DEFAULT,
|
||||
);
|
||||
if (method && typeof s.consensusLevel === "number" && Number.isFinite(s.consensusLevel)) {
|
||||
entry.consensusLevel = asNumberClamped(s.consensusLevel, 0, 100, 0);
|
||||
}
|
||||
return entry;
|
||||
}
|
||||
@@ -189,35 +181,29 @@ export function emptyConflictManagementDetails(): ConflictManagementDetailEntry
|
||||
export function coreValuePresetFor(chipId: string): CoreValueDetailEntry {
|
||||
const values = (coreValuesMessages as { values?: unknown }).values;
|
||||
if (!Array.isArray(values)) return { meaning: "", signals: "" };
|
||||
const idx = Number.parseInt(chipId, 10);
|
||||
if (!Number.isInteger(idx) || idx < 1 || idx > values.length) {
|
||||
return { meaning: "", signals: "" };
|
||||
for (const row of values) {
|
||||
if (!row || typeof row !== "object") continue;
|
||||
const o = row as Record<string, unknown>;
|
||||
if (o.id !== chipId) continue;
|
||||
return {
|
||||
meaning: asString(o.meaning),
|
||||
signals: asString(o.signals),
|
||||
};
|
||||
}
|
||||
const row = values[idx - 1];
|
||||
if (!row || typeof row !== "object") return { meaning: "", signals: "" };
|
||||
const o = row as Record<string, unknown>;
|
||||
return {
|
||||
meaning: asString(o.meaning),
|
||||
signals: asString(o.signals),
|
||||
};
|
||||
return { meaning: "", signals: "" };
|
||||
}
|
||||
|
||||
/**
|
||||
* Preset chip id for a core value label (`"1"` … `"n"` from
|
||||
* `CoreValuesSelectScreen`), or `null` when the label is bespoke.
|
||||
*/
|
||||
/** Preset chip id for a core value label, or `null` when the label is bespoke. */
|
||||
export function resolveCoreValueChipIdFromLabel(label: string): string | null {
|
||||
const t = label.trim();
|
||||
if (!t) return null;
|
||||
const values = (coreValuesMessages as { values?: unknown }).values;
|
||||
if (!Array.isArray(values)) return null;
|
||||
for (let i = 0; i < values.length; i++) {
|
||||
const row = values[i];
|
||||
if (typeof row === "string" && row.trim() === t) return String(i + 1);
|
||||
for (const row of values) {
|
||||
if (!row || typeof row !== "object") continue;
|
||||
const o = row as Record<string, unknown>;
|
||||
if (typeof o.label === "string" && o.label.trim() === t) {
|
||||
return String(i + 1);
|
||||
if (typeof o.label === "string" && o.label.trim() === t && typeof o.id === "string") {
|
||||
return o.id;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import type { CreateFlowState } from "../../app/(app)/create/types";
|
||||
import type { RequestedFacets } from "../server/validation/methodFacetsSchemas";
|
||||
import {
|
||||
resolveMaturityChipId,
|
||||
resolveOrgChipId,
|
||||
resolveScaleChipId,
|
||||
resolveSizeChipId,
|
||||
} from "./communityChipIds";
|
||||
import type { GroupAnswers, Kind, Scale, Stage, TraitKey } from "./deckTypes";
|
||||
import { KINDS, SCALES, STAGES, TRAIT_KEYS } from "./deckTypes";
|
||||
|
||||
function isKind(value: string): value is Kind {
|
||||
return (KINDS as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
function isScale(value: string): value is Scale {
|
||||
return (SCALES as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
function isStage(value: string): value is Stage {
|
||||
return (STAGES as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
/** Larger organization and Enterprise both score as Established. */
|
||||
export function stageForScoring(value: string | null | undefined): Stage | null {
|
||||
if (!value) return null;
|
||||
if (value === "enterprise" || value === "larger-organization") return "established";
|
||||
return isStage(value) ? value : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* One size, one main kind, every ticked scale, and one maturity answer.
|
||||
* Extra maturity answers stay on the draft and do not add points.
|
||||
*/
|
||||
export function groupAnswersFromRequestedFacets(
|
||||
facets: RequestedFacets,
|
||||
traits?: Partial<Record<TraitKey, boolean>>,
|
||||
): GroupAnswers {
|
||||
const kinds = (facets.orgType ?? []).filter(isKind);
|
||||
const scales = (facets.scale ?? []).filter(isScale);
|
||||
const maturity = (facets.maturity ?? [])
|
||||
.map((value) => stageForScoring(value))
|
||||
.filter((value): value is Stage => value !== null);
|
||||
return {
|
||||
size: facets.size?.[0] ?? null,
|
||||
mainKind: kinds[0] ?? null,
|
||||
otherKinds: kinds.slice(1),
|
||||
scale: scales,
|
||||
stage: maturity[0] ?? null,
|
||||
traits,
|
||||
};
|
||||
}
|
||||
|
||||
function traitRecord(
|
||||
raw: CreateFlowState["setupTraits"],
|
||||
): Partial<Record<TraitKey, boolean>> | undefined {
|
||||
if (!raw) return undefined;
|
||||
const out: Partial<Record<TraitKey, boolean>> = {};
|
||||
for (const key of TRAIT_KEYS) {
|
||||
if (typeof raw[key] === "boolean") out[key] = raw[key];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function groupAnswersFromCreateFlowState(
|
||||
state: CreateFlowState,
|
||||
): GroupAnswers {
|
||||
const size = (state.selectedCommunitySizeIds ?? []).map(resolveSizeChipId)[0] ?? null;
|
||||
const kinds = (state.selectedOrganizationTypeIds ?? [])
|
||||
.map((id) => resolveOrgChipId(id))
|
||||
.filter(isKind);
|
||||
const scale = (state.selectedScaleIds ?? [])
|
||||
.map(resolveScaleChipId)
|
||||
.filter(isScale);
|
||||
const maturity = (state.selectedMaturityIds ?? [])
|
||||
.map((id) => stageForScoring(resolveMaturityChipId(id)))
|
||||
.filter((value): value is Stage => value !== null);
|
||||
const uniqueScale = [...new Set(scale)];
|
||||
return {
|
||||
size,
|
||||
mainKind: kinds[0] ?? null,
|
||||
otherKinds: kinds.slice(1).filter((kind, index, all) => all.indexOf(kind) === index),
|
||||
scale: uniqueScale,
|
||||
stage: maturity[0] ?? null,
|
||||
traits: traitRecord(state.setupTraits),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
import type { CreateFlowState } from "../../app/(app)/create/types";
|
||||
import idAliasesFile from "../../data/create/customRule/_idAliases.json";
|
||||
import type { DeckId } from "./deckTypes";
|
||||
import { DECK_IDS } from "./deckTypes";
|
||||
|
||||
type MovedTo = { deck: string; id: string };
|
||||
type MethodAlias =
|
||||
| string
|
||||
| {
|
||||
target: string | null;
|
||||
movedTo?: MovedTo[];
|
||||
};
|
||||
|
||||
type IdAliasesFile = {
|
||||
methods: Record<string, Record<string, MethodAlias>>;
|
||||
coreValues: {
|
||||
oldPositionToNewId: Record<string, string | null>;
|
||||
labelMerges: Record<string, string | null>;
|
||||
};
|
||||
};
|
||||
|
||||
const aliases = idAliasesFile as IdAliasesFile;
|
||||
|
||||
function isDeckId(value: string): value is DeckId {
|
||||
return (DECK_IDS as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
export type ResolvedMethodId = { deck: DeckId; id: string };
|
||||
|
||||
/**
|
||||
* Old card id → new card id. A cut card follows `movedTo` when that card
|
||||
* lives in a deck. `null` means the card was cut and has nowhere to go.
|
||||
*/
|
||||
export function resolveMethodId(
|
||||
deck: DeckId,
|
||||
id: string,
|
||||
): ResolvedMethodId | null {
|
||||
const raw = aliases.methods[deck]?.[id];
|
||||
if (raw === undefined) return { deck, id };
|
||||
if (typeof raw === "string") return { deck, id: raw };
|
||||
if (typeof raw.target === "string" && raw.target.length > 0) {
|
||||
return { deck, id: raw.target };
|
||||
}
|
||||
const moved = raw.movedTo?.find((row) => isDeckId(row.deck));
|
||||
if (moved && isDeckId(moved.deck)) return { deck: moved.deck, id: moved.id };
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Old 1-based core-value position → stable id. `null` means the value was cut. */
|
||||
export function resolveCoreValueId(id: string): string | null {
|
||||
if (Object.prototype.hasOwnProperty.call(aliases.coreValues.oldPositionToNewId, id)) {
|
||||
return aliases.coreValues.oldPositionToNewId[id] ?? null;
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
export function resolveCoreValueLabel(label: string): string | null {
|
||||
if (Object.prototype.hasOwnProperty.call(aliases.coreValues.labelMerges, label)) {
|
||||
return aliases.coreValues.labelMerges[label];
|
||||
}
|
||||
return label;
|
||||
}
|
||||
|
||||
function remapIdList(
|
||||
ids: string[] | undefined,
|
||||
deck: DeckId,
|
||||
into: Map<DeckId, string[]>,
|
||||
): void {
|
||||
if (!ids) return;
|
||||
for (const id of ids) {
|
||||
const next = resolveMethodId(deck, id);
|
||||
if (!next) continue;
|
||||
const list = into.get(next.deck) ?? [];
|
||||
if (!list.includes(next.id)) list.push(next.id);
|
||||
into.set(next.deck, list);
|
||||
}
|
||||
}
|
||||
|
||||
const METHOD_ID_KEYS = {
|
||||
decisionApproaches: "selectedDecisionApproachIds",
|
||||
membership: "selectedMembershipMethodIds",
|
||||
conflictManagement: "selectedConflictManagementIds",
|
||||
communication: "selectedCommunicationMethodIds",
|
||||
} as const satisfies Record<DeckId, keyof CreateFlowState>;
|
||||
|
||||
const DETAIL_KEYS = {
|
||||
decisionApproaches: "decisionApproachDetailsById",
|
||||
membership: "membershipMethodDetailsById",
|
||||
conflictManagement: "conflictManagementDetailsById",
|
||||
communication: "communicationMethodDetailsById",
|
||||
} as const satisfies Record<DeckId, keyof CreateFlowState>;
|
||||
|
||||
/**
|
||||
* Remap saved method and core-value ids onto the remade decks.
|
||||
* Safe to run more than once: stable ids pass through.
|
||||
*/
|
||||
export function migrateSavedDeckIds(
|
||||
state: CreateFlowState,
|
||||
): CreateFlowState {
|
||||
const byDeck = new Map<DeckId, string[]>();
|
||||
for (const deck of DECK_IDS) {
|
||||
const key = METHOD_ID_KEYS[deck];
|
||||
const ids = state[key];
|
||||
remapIdList(Array.isArray(ids) ? (ids as string[]) : undefined, deck, byDeck);
|
||||
}
|
||||
|
||||
const detailsByDeck = new Map<DeckId, Record<string, unknown>>();
|
||||
for (const deck of DECK_IDS) {
|
||||
const key = DETAIL_KEYS[deck];
|
||||
const details = state[key];
|
||||
if (!details || typeof details !== "object") continue;
|
||||
for (const [id, value] of Object.entries(details as Record<string, unknown>)) {
|
||||
const resolved = resolveMethodId(deck, id);
|
||||
if (!resolved) continue;
|
||||
const next = detailsByDeck.get(resolved.deck) ?? {};
|
||||
if (next[resolved.id] === undefined) next[resolved.id] = value;
|
||||
detailsByDeck.set(resolved.deck, next);
|
||||
}
|
||||
}
|
||||
const detailsOut: Partial<CreateFlowState> = {};
|
||||
for (const deck of DECK_IDS) {
|
||||
if (!detailsByDeck.has(deck) && state[DETAIL_KEYS[deck]] === undefined) continue;
|
||||
(detailsOut as Record<string, unknown>)[DETAIL_KEYS[deck]] =
|
||||
detailsByDeck.get(deck) ?? {};
|
||||
}
|
||||
|
||||
const coreIds: string[] = [];
|
||||
const seenValues = new Set<string>();
|
||||
for (const id of state.selectedCoreValueIds ?? []) {
|
||||
const next = resolveCoreValueId(id);
|
||||
if (!next || seenValues.has(next)) continue;
|
||||
seenValues.add(next);
|
||||
coreIds.push(next);
|
||||
}
|
||||
|
||||
const coreDetails = state.coreValueDetailsByChipId;
|
||||
let nextCoreDetails = coreDetails;
|
||||
if (coreDetails) {
|
||||
nextCoreDetails = {};
|
||||
for (const [id, value] of Object.entries(coreDetails)) {
|
||||
const next = resolveCoreValueId(id);
|
||||
if (!next || nextCoreDetails[next]) continue;
|
||||
nextCoreDetails[next] = value;
|
||||
}
|
||||
}
|
||||
|
||||
const seenSnapshot = new Set<string>();
|
||||
const snapshot = state.coreValuesChipsSnapshot?.flatMap((row) => {
|
||||
const nextId = resolveCoreValueId(row.id);
|
||||
if (!nextId || seenSnapshot.has(nextId)) return [];
|
||||
seenSnapshot.add(nextId);
|
||||
const mergedLabel = resolveCoreValueLabel(row.label);
|
||||
if (mergedLabel === null) return [];
|
||||
return [{ ...row, id: nextId, label: mergedLabel }];
|
||||
});
|
||||
|
||||
const methodPatch: Partial<CreateFlowState> = {};
|
||||
for (const deck of DECK_IDS) {
|
||||
const key = METHOD_ID_KEYS[deck];
|
||||
if (state[key] !== undefined || byDeck.has(deck)) {
|
||||
(methodPatch as Record<string, unknown>)[key] = byDeck.get(deck) ?? [];
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
...methodPatch,
|
||||
...detailsOut,
|
||||
...(state.selectedCoreValueIds
|
||||
? { selectedCoreValueIds: coreIds }
|
||||
: {}),
|
||||
...(snapshot ? { coreValuesChipsSnapshot: snapshot } : {}),
|
||||
...(nextCoreDetails ? { coreValueDetailsByChipId: nextCoreDetails } : {}),
|
||||
};
|
||||
}
|
||||
@@ -1,24 +1,89 @@
|
||||
import type { CreateFlowState } from "../../app/(app)/create/types";
|
||||
import type {
|
||||
CommunityStructureChipSnapshotRow,
|
||||
CreateFlowState,
|
||||
} from "../../app/(app)/create/types";
|
||||
import communityStructure from "../../messages/en/create/community/communityStructure.json";
|
||||
import {
|
||||
migrateChipSnapshot,
|
||||
resolveChipIdList,
|
||||
resolveMaturityChipId,
|
||||
resolveOrgChipId,
|
||||
resolveScaleChipId,
|
||||
resolveSizeChipId,
|
||||
} from "./communityChipIds";
|
||||
import { migrateSavedDeckIds } from "./idAliases";
|
||||
|
||||
/** Legacy `currentStep` values mapped to the current `CreateFlowStep` id. */
|
||||
const LEGACY_CREATE_FLOW_STEP_RENAMES: Readonly<Record<string, string>> = {
|
||||
"right-rail": "decision-approaches",
|
||||
};
|
||||
|
||||
function chipPresets(
|
||||
rows: readonly { id: string; label: string }[],
|
||||
): { id: string; label: string }[] {
|
||||
return rows.map((row) => ({ id: row.id, label: row.label }));
|
||||
}
|
||||
|
||||
function migrateCommunityChips(state: CreateFlowState): CreateFlowState {
|
||||
const snapshots = state.communityStructureChipSnapshots;
|
||||
const maturity = migrateChipSnapshot(
|
||||
snapshots?.maturity as CommunityStructureChipSnapshotRow[] | undefined,
|
||||
chipPresets(communityStructure.maturityOptions),
|
||||
resolveMaturityChipId,
|
||||
);
|
||||
const organizationTypes = migrateChipSnapshot(
|
||||
snapshots?.organizationTypes,
|
||||
chipPresets(communityStructure.organizationTypes),
|
||||
resolveOrgChipId,
|
||||
);
|
||||
const scale = migrateChipSnapshot(
|
||||
snapshots?.scale,
|
||||
chipPresets(communityStructure.scaleOptions),
|
||||
resolveScaleChipId,
|
||||
);
|
||||
return {
|
||||
...state,
|
||||
selectedCommunitySizeIds: resolveChipIdList(
|
||||
state.selectedCommunitySizeIds,
|
||||
resolveSizeChipId,
|
||||
),
|
||||
selectedOrganizationTypeIds: resolveChipIdList(
|
||||
state.selectedOrganizationTypeIds,
|
||||
resolveOrgChipId,
|
||||
),
|
||||
selectedScaleIds: resolveChipIdList(state.selectedScaleIds, resolveScaleChipId),
|
||||
selectedMaturityIds: resolveChipIdList(
|
||||
state.selectedMaturityIds,
|
||||
resolveMaturityChipId,
|
||||
),
|
||||
...(snapshots || maturity || organizationTypes || scale
|
||||
? {
|
||||
communityStructureChipSnapshots: {
|
||||
...snapshots,
|
||||
...(organizationTypes ? { organizationTypes } : {}),
|
||||
...(scale ? { scale } : {}),
|
||||
...(maturity ? { maturity } : {}),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes parsed draft JSON before merging into create-flow context.
|
||||
* Renames deprecated step ids so old drafts and bookmarks stay valid.
|
||||
* Renames deprecated step ids and remaps deck and chip ids from before the remake.
|
||||
*/
|
||||
export function migrateLegacyCreateFlowState(
|
||||
raw: Record<string, unknown> | null | undefined,
|
||||
): CreateFlowState {
|
||||
if (!raw || typeof raw !== "object") return {};
|
||||
const step = raw.currentStep;
|
||||
let state = raw as CreateFlowState;
|
||||
if (typeof step === "string") {
|
||||
const next = LEGACY_CREATE_FLOW_STEP_RENAMES[step];
|
||||
if (next !== undefined) {
|
||||
return { ...raw, currentStep: next } as CreateFlowState;
|
||||
state = { ...state, currentStep: next } as CreateFlowState;
|
||||
}
|
||||
}
|
||||
return raw as CreateFlowState;
|
||||
return migrateCommunityChips(migrateSavedDeckIds(state));
|
||||
}
|
||||
@@ -16,30 +16,30 @@ import type { TemplateFacetGroupKey } from "./templateReviewMapping";
|
||||
import { RULE_SECTION_CATEGORY } from "./ruleSectionsFromMethodSelections";
|
||||
|
||||
const COMM_LABELS: Record<string, string> = {
|
||||
corePrinciple: "Core Principle & Scope",
|
||||
logisticsAdmin: "Logistics, Admin & Norms",
|
||||
codeOfConduct: "Code of Conduct",
|
||||
corePrinciple: "Core principle and scope",
|
||||
logisticsAdmin: "How it works",
|
||||
codeOfConduct: "Norms",
|
||||
};
|
||||
|
||||
const MEM_LABELS: Record<string, string> = {
|
||||
eligibility: "Eligibility & Philosophy",
|
||||
joiningProcess: "Joining Process",
|
||||
expectations: "Expectations & Removal",
|
||||
eligibility: "Who it covers and why",
|
||||
joiningProcess: "How it works",
|
||||
expectations: "Expectations and limits",
|
||||
};
|
||||
|
||||
const DEC_LABELS: Record<string, string> = {
|
||||
corePrinciple: "Core Principle",
|
||||
applicableScope: "Applicable Scope",
|
||||
stepByStepInstructions: "Step-by-Step Instructions",
|
||||
consensusLevel: "Consensus Level",
|
||||
objectionsDeadlocks: "Objections & Deadlocks",
|
||||
corePrinciple: "Core principle",
|
||||
applicableScope: "Applicable scope",
|
||||
stepByStepInstructions: "Step-by-step instructions",
|
||||
consensusLevel: "Consensus level",
|
||||
objectionsDeadlocks: "Objections and deadlocks",
|
||||
};
|
||||
|
||||
const CM_LABELS: Record<string, string> = {
|
||||
corePrinciple: "Core Principle",
|
||||
applicableScope: "Applicable Scope",
|
||||
processProtocol: "Process Protocol",
|
||||
restorationFallbacks: "Restoration & Fallbacks",
|
||||
corePrinciple: "Core principle",
|
||||
applicableScope: "Applicable scope",
|
||||
processProtocol: "How it works",
|
||||
restorationFallbacks: "Protections and fallbacks",
|
||||
};
|
||||
|
||||
const LABELS_BY_GROUP: Record<
|
||||
|
||||
@@ -10,6 +10,7 @@ import type { PublishedMethodSelections } from "./buildPublishPayload";
|
||||
import type { StoredLastPublishedRule } from "./lastPublishedRule";
|
||||
import { normalizePublishedDocumentForEdit } from "./normalizePublishedDocumentForEdit";
|
||||
import { methodLabelFor } from "./finalReviewChipPresets";
|
||||
import { migrateSavedDeckIds } from "./idAliases";
|
||||
import type { TemplateFacetGroupKey } from "./templateReviewMapping";
|
||||
|
||||
function customMethodCardMetaFromPublishedSelections(
|
||||
@@ -236,5 +237,5 @@ export function createFlowStateFromPublishedRule(
|
||||
|
||||
/** Drop template `sections` so final-review uses `methodSelections` / selected ids (edit path). */
|
||||
out.sections = [];
|
||||
return out;
|
||||
return migrateSavedDeckIds(out);
|
||||
}
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
sectionFromDecision,
|
||||
sectionFromMembership,
|
||||
} from "./ruleSectionsFromMethodSelections";
|
||||
import { withSharedTerms } from "./sharedTermsSection";
|
||||
import {
|
||||
templateCategoryToGroupKey,
|
||||
type TemplateFacetGroupKey,
|
||||
@@ -50,30 +51,30 @@ const CANONICAL_DISPLAY_SECTION_ORDER = [
|
||||
] as const;
|
||||
|
||||
const COMM_LABELS: Record<string, string> = {
|
||||
corePrinciple: "Core Principle & Scope",
|
||||
logisticsAdmin: "Logistics, Admin & Norms",
|
||||
codeOfConduct: "Code of Conduct",
|
||||
corePrinciple: "Core principle and scope",
|
||||
logisticsAdmin: "How it works",
|
||||
codeOfConduct: "Norms",
|
||||
};
|
||||
|
||||
const MEM_LABELS: Record<string, string> = {
|
||||
eligibility: "Eligibility & Philosophy",
|
||||
joiningProcess: "Joining Process",
|
||||
expectations: "Expectations & Removal",
|
||||
eligibility: "Who it covers and why",
|
||||
joiningProcess: "How it works",
|
||||
expectations: "Expectations and limits",
|
||||
};
|
||||
|
||||
const DEC_LABELS: Record<string, string> = {
|
||||
corePrinciple: "Core Principle",
|
||||
applicableScope: "Applicable Scope",
|
||||
stepByStepInstructions: "Step-by-Step Instructions",
|
||||
consensusLevel: "Consensus Level",
|
||||
objectionsDeadlocks: "Objections & Deadlocks",
|
||||
corePrinciple: "Core principle",
|
||||
applicableScope: "Applicable scope",
|
||||
stepByStepInstructions: "Step-by-step instructions",
|
||||
consensusLevel: "Consensus level",
|
||||
objectionsDeadlocks: "Objections and deadlocks",
|
||||
};
|
||||
|
||||
const CM_LABELS: Record<string, string> = {
|
||||
corePrinciple: "Core Principle",
|
||||
applicableScope: "Applicable Scope",
|
||||
processProtocol: "Process Protocol",
|
||||
restorationFallbacks: "Restoration & Fallbacks",
|
||||
corePrinciple: "Core principle",
|
||||
applicableScope: "Applicable scope",
|
||||
processProtocol: "How it works",
|
||||
restorationFallbacks: "Protections and fallbacks",
|
||||
};
|
||||
|
||||
function needsPlaceholderPresetEnrichment(entry: CommunityRuleEntry): boolean {
|
||||
@@ -305,6 +306,7 @@ export function parsePublishedDocumentForCommunityRuleDisplay(
|
||||
);
|
||||
}
|
||||
|
||||
const combined = displaySections.map(enrichDisplaySection);
|
||||
return sortSectionsCanonical(combined);
|
||||
const sorted = sortSectionsCanonical(displaySections.map(enrichDisplaySection));
|
||||
if (sorted.length === 0) return sorted;
|
||||
return withSharedTerms(sorted);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* The 92 real-group profiles from product/real-group-profiles.md, read as setup answers,
|
||||
* and the one-member founders added to the test set on 27 Oct.
|
||||
* The real-group fixture is copied unchanged. Two readings are made here, both stated
|
||||
* in the README. The first kind listed is the main kind, as the review read its worked
|
||||
* examples. An alsoPlausible kind is read as another kind that fits.
|
||||
*/
|
||||
import type { GroupAnswers, Kind, Scale, SizeBand, Stage, TraitKey } from "./deckTypes";
|
||||
|
||||
export interface ReviewProfile {
|
||||
id: string;
|
||||
size: string | null;
|
||||
kinds: string[];
|
||||
scale: string | null;
|
||||
stage: string | null;
|
||||
answers: Record<TraitKey, boolean>;
|
||||
alsoPlausible?: { size?: string[]; kinds?: string[]; scale?: string[]; stage?: string[] };
|
||||
}
|
||||
|
||||
const KIND: Record<string, Kind> = {
|
||||
"worker co-op": "workersCoop",
|
||||
"mutual aid": "mutualAid",
|
||||
nonprofit: "nonprofit",
|
||||
"for-profit": "forProfit",
|
||||
"open source": "openSource",
|
||||
dao: "dao",
|
||||
};
|
||||
const SIZE: Record<string, SizeBand> = {
|
||||
"1": "oneMember",
|
||||
"2-5": "twoToFive",
|
||||
"6-12": "sixToTwelve",
|
||||
"13-100": "thirteenToOneHundred",
|
||||
"100+": "oneHundredToOneHundredK",
|
||||
};
|
||||
const STAGE: Record<string, Stage> = {
|
||||
early: "earlyStage",
|
||||
growth: "growthStage",
|
||||
established: "established",
|
||||
enterprise: "enterprise",
|
||||
};
|
||||
|
||||
function kind(k: string): Kind {
|
||||
const v = KIND[k];
|
||||
if (!v) throw new Error(`Unknown kind ${k}`);
|
||||
return v;
|
||||
}
|
||||
|
||||
export function toAnswers(p: ReviewProfile): GroupAnswers {
|
||||
const kinds = p.kinds.map(kind);
|
||||
return {
|
||||
size: p.size ? SIZE[p.size] : null,
|
||||
mainKind: kinds[0] ?? null,
|
||||
otherKinds: kinds.slice(1),
|
||||
scale: p.scale ? [p.scale as Scale] : [],
|
||||
stage: p.stage ? STAGE[p.stage] : null,
|
||||
traits: { ...p.answers },
|
||||
};
|
||||
}
|
||||
|
||||
export interface ProfileVariant {
|
||||
profile: string;
|
||||
variant: string;
|
||||
answers: GroupAnswers;
|
||||
}
|
||||
|
||||
/** The profile as given, then once per alsoPlausible answer swapped in. */
|
||||
export function variantsOf(p: ReviewProfile): ProfileVariant[] {
|
||||
const base = toAnswers(p);
|
||||
const out: ProfileVariant[] = [{ profile: p.id, variant: "base", answers: base }];
|
||||
for (const s of p.alsoPlausible?.size ?? []) {
|
||||
out.push({ profile: p.id, variant: `size ${s}`, answers: { ...base, size: SIZE[s] } });
|
||||
}
|
||||
for (const k of p.alsoPlausible?.kinds ?? []) {
|
||||
out.push({
|
||||
profile: p.id,
|
||||
variant: `also ${k}`,
|
||||
answers: { ...base, otherKinds: [...(base.otherKinds ?? []), kind(k)] },
|
||||
});
|
||||
}
|
||||
for (const s of p.alsoPlausible?.scale ?? []) {
|
||||
out.push({ profile: p.id, variant: `scale ${s}`, answers: { ...base, scale: [s as Scale] } });
|
||||
}
|
||||
for (const s of p.alsoPlausible?.stage ?? []) {
|
||||
out.push({ profile: p.id, variant: `stage ${s}`, answers: { ...base, stage: STAGE[s] } });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import type {
|
||||
} from "../../app/components/type/CommunityRule/CommunityRule.types";
|
||||
import type { PublishedMethodSelections } from "./buildPublishPayload";
|
||||
import type { CustomMethodCardFieldBlock } from "./customMethodCardFieldBlocks";
|
||||
import decisionApproachesMessages from "../../messages/en/create/customRule/decisionApproaches.json";
|
||||
import { decisionApproachScopeForPublish } from "./decisionApproachKeyResources";
|
||||
import { templateCategoryToGroupKey } from "./templateReviewMapping";
|
||||
|
||||
@@ -77,6 +78,11 @@ export function labeledBlocksFromCustomMethodCardFieldBlocks(
|
||||
|
||||
export type CommunityRuleEntryFromChipOptions = {
|
||||
consensusLevelKey?: string;
|
||||
/**
|
||||
* Copy used when `consensusLevel` is absent. `null` means the figure was
|
||||
* dropped on purpose (a seeded share on an empty custom card) and must not print.
|
||||
*/
|
||||
missingConsensusBody?: string | null;
|
||||
customFieldBlocks?: CustomMethodCardFieldBlock[];
|
||||
/** Wizard step-2 policy description (`customMethodCardMetaById.supportText`). */
|
||||
supportText?: string;
|
||||
@@ -92,32 +98,42 @@ export const RULE_SECTION_CATEGORY = {
|
||||
} as const;
|
||||
|
||||
const COMM_LABELS: Record<string, string> = {
|
||||
corePrinciple: "Core Principle & Scope",
|
||||
logisticsAdmin: "Logistics, Admin & Norms",
|
||||
codeOfConduct: "Code of Conduct",
|
||||
corePrinciple: "Core principle and scope",
|
||||
logisticsAdmin: "How it works",
|
||||
codeOfConduct: "Norms",
|
||||
};
|
||||
|
||||
const MEM_LABELS: Record<string, string> = {
|
||||
eligibility: "Eligibility & Philosophy",
|
||||
joiningProcess: "Joining Process",
|
||||
expectations: "Expectations & Removal",
|
||||
eligibility: "Who it covers and why",
|
||||
joiningProcess: "How it works",
|
||||
expectations: "Expectations and limits",
|
||||
};
|
||||
|
||||
const DEC_LABELS: Record<string, string> = {
|
||||
corePrinciple: "Core Principle",
|
||||
applicableScope: "Applicable Scope",
|
||||
stepByStepInstructions: "Step-by-Step Instructions",
|
||||
consensusLevel: "Consensus Level",
|
||||
objectionsDeadlocks: "Objections & Deadlocks",
|
||||
corePrinciple: "Core principle",
|
||||
applicableScope: "Applicable scope",
|
||||
stepByStepInstructions: "Step-by-step instructions",
|
||||
consensusLevel: "Consensus level",
|
||||
objectionsDeadlocks: "Objections and deadlocks",
|
||||
};
|
||||
|
||||
const CM_LABELS: Record<string, string> = {
|
||||
corePrinciple: "Core Principle",
|
||||
applicableScope: "Applicable Scope",
|
||||
processProtocol: "Process Protocol",
|
||||
restorationFallbacks: "Restoration & Fallbacks",
|
||||
corePrinciple: "Core principle",
|
||||
applicableScope: "Applicable scope",
|
||||
processProtocol: "How it works",
|
||||
restorationFallbacks: "Protections and fallbacks",
|
||||
};
|
||||
|
||||
function consensusLevelBody(
|
||||
value: unknown,
|
||||
whenMissing: string | null,
|
||||
): string | null {
|
||||
const copy = decisionApproachesMessages.consensusLevelDisplay;
|
||||
if (typeof value !== "number" || Number.isNaN(value)) return whenMissing;
|
||||
if (value === 0) return copy.noSetShare;
|
||||
return `${value}%`;
|
||||
}
|
||||
|
||||
export function nonEmptyTrimmed(s: unknown): string | null {
|
||||
if (typeof s !== "string") return null;
|
||||
const t = s.trim();
|
||||
@@ -158,15 +174,18 @@ export function blocksFromKeyedRecord(
|
||||
labelByKey: Record<string, string>,
|
||||
options?: {
|
||||
consensusLevelKey?: string;
|
||||
missingConsensusBody?: string | null;
|
||||
},
|
||||
): CommunityRuleLabeledBlock[] {
|
||||
const blocks: CommunityRuleLabeledBlock[] = [];
|
||||
const whenMissing =
|
||||
options?.missingConsensusBody === undefined
|
||||
? decisionApproachesMessages.consensusLevelDisplay.supermajorityShare
|
||||
: options.missingConsensusBody;
|
||||
for (const [key, label] of Object.entries(labelByKey)) {
|
||||
if (options?.consensusLevelKey === key) {
|
||||
const n = sections[key];
|
||||
if (typeof n === "number" && !Number.isNaN(n)) {
|
||||
blocks.push({ label, body: `${n}%` });
|
||||
}
|
||||
const body = consensusLevelBody(sections[key], whenMissing);
|
||||
if (body) blocks.push({ label, body });
|
||||
continue;
|
||||
}
|
||||
const raw = sections[key];
|
||||
@@ -189,7 +208,10 @@ export function communityRuleEntryFromMethodChip(
|
||||
sections,
|
||||
labelByKey,
|
||||
options?.consensusLevelKey
|
||||
? { consensusLevelKey: options.consensusLevelKey }
|
||||
? {
|
||||
consensusLevelKey: options.consensusLevelKey,
|
||||
missingConsensusBody: options.missingConsensusBody,
|
||||
}
|
||||
: undefined,
|
||||
);
|
||||
const wizardBlocks =
|
||||
@@ -323,6 +345,11 @@ export function sectionFromDecision(
|
||||
DEC_LABELS,
|
||||
{
|
||||
consensusLevelKey: "consensusLevel",
|
||||
missingConsensusBody:
|
||||
typeof sec.consensusLevel === "number" &&
|
||||
!Number.isNaN(sec.consensusLevel)
|
||||
? null
|
||||
: undefined,
|
||||
customFieldBlocks: customFieldBlocksById?.[m.id],
|
||||
supportText: m.supportText,
|
||||
},
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { CommunityRuleSection } from "../../app/components/type/CommunityRule/CommunityRule.types";
|
||||
import sharedTerms from "../../messages/en/create/sharedTerms.json";
|
||||
|
||||
/** Fixed at launch. Cards that omit a consensus level read this share. */
|
||||
export const LAUNCH_SUPERMAJORITY_SHARE = 75;
|
||||
|
||||
type SharedTerm = { term: string; meaning: string };
|
||||
|
||||
/**
|
||||
* Every shared term, definitions only. Fallbacks for a skipped topic do not print.
|
||||
* Use-case example Rules do not call this.
|
||||
*/
|
||||
export function sharedTermsSection(): CommunityRuleSection {
|
||||
const terms = (sharedTerms as { terms: SharedTerm[] }).terms;
|
||||
return {
|
||||
categoryName: sharedTerms.sectionTitle,
|
||||
entries: terms.map((term) => ({
|
||||
title: term.term,
|
||||
body: term.meaning,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export function withSharedTerms(
|
||||
sections: CommunityRuleSection[],
|
||||
): CommunityRuleSection[] {
|
||||
if (sections.some((section) => section.categoryName === sharedTerms.sectionTitle)) {
|
||||
return sections;
|
||||
}
|
||||
return [...sections, sharedTermsSection()];
|
||||
}
|
||||
@@ -21,7 +21,7 @@ export type CatalogMethodDto = {
|
||||
};
|
||||
|
||||
export type CatalogCoreValueDto = {
|
||||
/** 1-based position in `coreValues.json` (`"1"`, `"2"`, …). */
|
||||
/** Stable id from `coreValues.json` `values[].id`. */
|
||||
id: string;
|
||||
label: string;
|
||||
meaning: string;
|
||||
@@ -107,23 +107,17 @@ export function getCatalogMethod(
|
||||
return row ? rowToCatalogMethod(row) : null;
|
||||
}
|
||||
|
||||
/** All preset core values, in `coreValues.json` order (ids `"1"` … `"n"`). */
|
||||
/** All preset core values, in `coreValues.json` order, keyed by stable id. */
|
||||
export function listCatalogCoreValues(): CatalogCoreValueDto[] {
|
||||
const values = (coreValuesMessages as { values?: unknown }).values;
|
||||
if (!Array.isArray(values)) return [];
|
||||
const out: CatalogCoreValueDto[] = [];
|
||||
for (let i = 0; i < values.length; i++) {
|
||||
const row = values[i];
|
||||
const id = String(i + 1);
|
||||
if (typeof row === "string") {
|
||||
out.push({ id, label: row, meaning: "", signals: "" });
|
||||
continue;
|
||||
}
|
||||
for (const row of values) {
|
||||
if (!row || typeof row !== "object") continue;
|
||||
const o = row as Record<string, unknown>;
|
||||
if (typeof o.label !== "string") continue;
|
||||
if (typeof o.id !== "string" || typeof o.label !== "string") continue;
|
||||
out.push({
|
||||
id,
|
||||
id: o.id,
|
||||
label: o.label,
|
||||
meaning: typeof o.meaning === "string" ? o.meaning : "",
|
||||
signals: typeof o.signals === "string" ? o.signals : "",
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { groupAnswersFromRequestedFacets } from "../create/groupAnswers";
|
||||
import { deckData, recommendDeck } from "../create/deckRecommend";
|
||||
import { prisma } from "./db";
|
||||
import { isDatabaseConfigured } from "./env";
|
||||
import {
|
||||
@@ -27,57 +29,40 @@ export type ListMethodRecommendationsResult = {
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the per-method match scores for `section`, given `facets`.
|
||||
* Returns `null` so callers can fall back to messages-file order when DB
|
||||
* is unavailable or the query fails.
|
||||
*
|
||||
* Notes:
|
||||
* - Empty facets ⇒ `rankedSlugs: []`, `matchesBySlug: {}` (caller falls back
|
||||
* to authoring order).
|
||||
* - Sort is `score` desc only — re-stabilising into authoring order is the
|
||||
* caller's job (the wizard already iterates the on-disk `methods[]` array).
|
||||
* Ranks one deck with the job rule. Empty facets return no scores so callers
|
||||
* keep deck order. The first maturity value is the only one that scores.
|
||||
* Scores decrease along the engine's display order so a score sort matches it.
|
||||
*/
|
||||
export async function listMethodRecommendations(args: {
|
||||
section: SectionId;
|
||||
facets: RequestedFacets;
|
||||
}): Promise<ListMethodRecommendationsResult | null> {
|
||||
if (!isDatabaseConfigured()) return null;
|
||||
|
||||
const requested = flattenRequestedFacets(args.facets);
|
||||
if (requested.length === 0) {
|
||||
return { rankedSlugs: [], matchesBySlug: {} };
|
||||
}
|
||||
|
||||
try {
|
||||
const rows = await prisma.methodFacet.findMany({
|
||||
where: {
|
||||
section: args.section,
|
||||
matches: true,
|
||||
OR: requested.map(({ group, value }) => ({ group, value })),
|
||||
},
|
||||
select: { slug: true, group: true, value: true },
|
||||
});
|
||||
|
||||
const matchesBySlug: Record<string, MethodRanking["matches"]> = {};
|
||||
for (const row of rows) {
|
||||
const key = `${row.group}:${row.value}`;
|
||||
const entry =
|
||||
matchesBySlug[row.slug] ??
|
||||
(matchesBySlug[row.slug] = { score: 0, matchedFacets: [] });
|
||||
if (!entry.matchedFacets.includes(key)) {
|
||||
entry.matchedFacets.push(key);
|
||||
entry.score += 1;
|
||||
}
|
||||
}
|
||||
|
||||
const rankedSlugs = Object.entries(matchesBySlug)
|
||||
.sort(([, a], [, b]) => b.score - a.score)
|
||||
.map(([slug]) => slug);
|
||||
|
||||
return { rankedSlugs, matchesBySlug };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const answers = groupAnswersFromRequestedFacets(args.facets);
|
||||
const recommendation = recommendDeck(deckData(args.section), answers);
|
||||
const orderedIds = recommendation.fallback
|
||||
? recommendation.seeAll
|
||||
: [
|
||||
...recommendation.shown.map((card) => card.id),
|
||||
...recommendation.seeAll,
|
||||
];
|
||||
const matchesBySlug: Record<string, MethodRanking["matches"]> = {};
|
||||
orderedIds.forEach((id, index) => {
|
||||
const card = recommendation.recommended.find((row) => row.id === id);
|
||||
const matched =
|
||||
card?.reason.kind === "fit" || card?.reason.kind === "opened"
|
||||
? card.reason.matched
|
||||
: [];
|
||||
matchesBySlug[id] = {
|
||||
score: orderedIds.length - index,
|
||||
matchedFacets: matched,
|
||||
};
|
||||
});
|
||||
return { rankedSlugs: orderedIds, matchesBySlug };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -115,6 +115,16 @@ export const createFlowStateSchema = z
|
||||
selectedOrganizationTypeIds: z.array(z.string()).optional(),
|
||||
selectedScaleIds: z.array(z.string()).optional(),
|
||||
selectedMaturityIds: z.array(z.string()).optional(),
|
||||
setupTraits: z
|
||||
.object({
|
||||
network: z.boolean().optional(),
|
||||
nonvoters: z.boolean().optional(),
|
||||
employer: z.boolean().optional(),
|
||||
sponsor: z.boolean().optional(),
|
||||
records: z.boolean().optional(),
|
||||
risky: z.boolean().optional(),
|
||||
})
|
||||
.optional(),
|
||||
communityStructureChipSnapshots:
|
||||
communityStructureChipSnapshotsSchema.optional(),
|
||||
selectedCoreValueIds: z.array(z.string()).max(200).optional(),
|
||||
|
||||
@@ -147,7 +147,7 @@ export const GOVERNANCE_TEMPLATE_HOME_SLUGS: readonly string[] = [
|
||||
"consensus",
|
||||
"do-ocracy",
|
||||
"devolution",
|
||||
"quadratic-governance",
|
||||
"solidarity-network",
|
||||
];
|
||||
|
||||
export function getGovernanceTemplatesForHome(): GovernanceTemplateCatalogEntry[] {
|
||||
|
||||
Reference in new issue
Block a user