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

+39 -5
View File
@@ -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 {};
+20 -4
View File
@@ -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();
+6 -1
View File
@@ -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;
}
+128
View File
@@ -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,
];
}
+1 -1
View File
@@ -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(
+8 -3
View File
@@ -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,
}));
+283
View File
@@ -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)) };
}
+137
View File
@@ -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[];
}
+15 -29
View File
@@ -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;
+87
View File
@@ -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),
};
}
+175
View File
@@ -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 } : {}),
};
}
+69 -4
View File
@@ -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));
}
+15 -15
View File
@@ -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);
}
+87
View File
@@ -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;
}
+47 -20
View File
@@ -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,
},
+31
View File
@@ -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()];
}
+5 -11
View File
@@ -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 : "",
+26 -41
View File
@@ -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(),
+1 -1
View File
@@ -147,7 +147,7 @@ export const GOVERNANCE_TEMPLATE_HOME_SLUGS: readonly string[] = [
"consensus",
"do-ocracy",
"devolution",
"quadratic-governance",
"solidarity-network",
];
export function getGovernanceTemplatesForHome(): GovernanceTemplateCatalogEntry[] {