/** * Self — private self-model data layer. * * Owns the single, home-local, never-shared record of the user's real self: * their values, neurotype assessments, and reflective Q&A responses. * * This is NOT a profile (outward-facing, many). This is the one private * self-model: values, neurotype, and meaning-alignment answers. * * Usage from other patterns: * const self = wish({ query: "#selfModel" }); */ import { computed, type Default, handler, ifElse, NAME, pattern, safeDateNow, UI, type VNode, Writable, } from "commonfabric"; // ============================================================================ // EXPORTED TYPES (verbatim from CT-1670 spec) // ============================================================================ export type NeurotypeSystem = "mbti" | "enneagram" | "big5" | "custom"; export interface Neurotype { system: NeurotypeSystem; result: string; // "INTJ", "5w4" detail?: Record; source: "self-reported" | "assessed"; recordedAt: number; } export interface ValueCard { title: string; description?: string; weight?: number; sourcePromptId?: string; // CT-1674: meaning-alignment fields attendingTo?: string; stance?: "descriptive" | "aspirational" | "conflicted"; contextTags?: string[]; sourceTrack?: Track; } // ============================================================================ // MEANING-ALIGNMENT TYPES (CT-1674) // ============================================================================ export type Track = "open" | "scissor" | "closing"; export interface ReflectivePrompt { id: string; text: string; kind: Track; } export const MEANING_PROMPTS: ReflectivePrompt[] = [ { id: "open-life", kind: "open", text: "Tell me a bit about your life right now — whatever you'd want a thoughtful system to know about you. Start with what you do and the most important people in your life.", }, { id: "sc-conflict", kind: "scissor", text: "Recall the last time a colleague or someone close said something that you thought was wrong, or that landed badly. What did you do with it — said something in the moment, followed up privately later, or let it go?", }, { id: "sc-waiting", kind: "scissor", text: "Think of the most recent time you were waiting on something that mattered and couldn't speed it up. Did you move your attention away from it, stay close and hold the tension, or start preparing for both outcomes?", }, { id: "sc-rhythms", kind: "scissor", text: "Is there a recurring shape to your weeks — a difference between some days and others, not just by schedule but by who you are in them? Is that contrast the whole shape of your life, a quiet undertone, or not really a thing for you?", }, { id: "sc-unscheduled", kind: "scissor", text: "Recall the most recent full day with no hard commitments — genuinely yours. By the end, did it feel good, wasted, or like a work day in different clothes?", }, { id: "sc-commitment", kind: "scissor", text: "Think back to the last time you committed to something for a friend or family member and it became clear you couldn't pull it off as promised. Did you tell them early, show up with a smaller version, or say nothing and scramble?", }, { id: "sc-narrative", kind: "scissor", text: "Think of the phase of life you're in right now. Last time you tried to name what it's for — could you name it, are you between chapters, or do you not really think in phases-with-purposes?", }, { id: "close-navigating", kind: "closing", text: "What are some things you're navigating right now that you'd want help with?", }, ]; export interface QAResponse { promptId: string; prompt: string; answer: string; track: "meaning" | "neurotype" | "freeform"; answeredAt: number; } export interface SelfModel { responses: QAResponse[]; values: ValueCard[]; neurotypes: Neurotype[]; } export const EMPTY_SELF_MODEL: SelfModel = { responses: [], values: [], neurotypes: [], }; // ============================================================================ // HANDLER EVENT PAYLOAD TYPES // ============================================================================ export interface RecordNeurotypeEvent { system: NeurotypeSystem; result: string; detail?: Record; source: "self-reported" | "assessed"; } export interface AddValueCardEvent { title: string; description?: string; weight?: number; sourcePromptId?: string; } export interface RecordResponseEvent { promptId: string; prompt: string; answer: string; track: "meaning" | "neurotype" | "freeform"; } export interface RemoveNeurotypeEvent { system: NeurotypeSystem; } export interface RemoveValueCardEvent { /** Zero-based index of the ValueCard to remove. */ index: number; } // ============================================================================ // PURE HELPERS (exported — these ARE the shipping mutation logic) // ============================================================================ /** * Upsert a neurotype entry by system. * Replaces the entry with the same `system` in-place, or appends if absent. */ export function upsertNeurotype( model: SelfModel, entry: Neurotype, ): SelfModel { const idx = model.neurotypes.findIndex((n) => n.system === entry.system); if (idx === -1) { return { ...model, neurotypes: [...model.neurotypes, entry] }; } return { ...model, neurotypes: model.neurotypes.map((n, i) => (i === idx ? entry : n)), }; } /** Return a new SelfModel with the ValueCard appended to `values`. */ export function appendValue(model: SelfModel, card: ValueCard): SelfModel { return { ...model, values: [...model.values, card] }; } /** Return a new SelfModel with the QAResponse appended to `responses`. */ export function appendResponse( model: SelfModel, response: QAResponse, ): SelfModel { return { ...model, responses: [...model.responses, response] }; } /** Return a new SelfModel with the neurotype for `system` removed. No-op if absent. */ export function withoutNeurotype( model: SelfModel, system: NeurotypeSystem, ): SelfModel { return { ...model, neurotypes: model.neurotypes.filter((n) => n.system !== system), }; } /** Return a new SelfModel with the ValueCard at zero-based `index` removed. */ export function withoutValueAt(model: SelfModel, index: number): SelfModel { return { ...model, values: model.values.filter((_, i) => i !== index), }; } // ============================================================================ // HANDLERS // ============================================================================ /** * Upsert a neurotype entry by system. * If an entry with the same `system` exists, it is replaced in-place. * Otherwise, it is appended. */ export const recordNeurotype = handler< RecordNeurotypeEvent, { selfModel: Writable } >(({ system, result, detail, source }, { selfModel }) => { const entry: Neurotype = { system, result, detail, source, recordedAt: safeDateNow(), }; selfModel.set(upsertNeurotype(selfModel.get(), entry)); }); /** Append a ValueCard. */ export const addValueCard = handler< AddValueCardEvent, { selfModel: Writable } >(({ title, description, weight, sourcePromptId }, { selfModel }) => { const card: ValueCard = { title, description, weight, sourcePromptId }; selfModel.set(appendValue(selfModel.get(), card)); }); /** Append a QAResponse. Timestamp is set automatically via safeDateNow(). */ export const recordResponse = handler< RecordResponseEvent, { selfModel: Writable } >(({ promptId, prompt, answer, track }, { selfModel }) => { const response: QAResponse = { promptId, prompt, answer, track, answeredAt: safeDateNow(), }; selfModel.set(appendResponse(selfModel.get(), response)); }); /** Remove the neurotype entry whose system matches. No-op if not found. */ export const removeNeurotype = handler< RemoveNeurotypeEvent, { selfModel: Writable } >(({ system }, { selfModel }) => { selfModel.set(withoutNeurotype(selfModel.get(), system)); }); /** * Remove a ValueCard by zero-based index. * Index-based removal is stable for the current append-only model; when * sourcePromptId is populated, callers may prefer to find by that field. */ export const removeValueCard = handler< RemoveValueCardEvent, { selfModel: Writable } >(({ index }, { selfModel }) => { selfModel.set(withoutValueAt(selfModel.get(), index)); }); // ============================================================================ // PATTERN INPUT / OUTPUT // ============================================================================ interface SelfInput { /** * Optional external Writable cell for the self-model. * When provided the pattern uses this cell directly (useful for testing * and for parent patterns that want to own the storage). * When omitted the pattern creates and owns its own durable cell. */ selfModel?: Writable>; } /** Private self-model — the user's values, neurotype, and reflective answers. #selfModel */ export interface SelfOutput { [NAME]: string; [UI]: VNode; selfModel: Writable; recordNeurotype: ReturnType; addValueCard: ReturnType; recordResponse: ReturnType; removeNeurotype: ReturnType; removeValueCard: ReturnType; } // ============================================================================ // FORM HANDLERS (state-reading, for UI binding) // ============================================================================ /** * Record a neurotype entry from form fields (state-reading handler). * Reads systemField + resultField from state; upserts into selfModel; * clears resultField after recording. */ export const recordNeurotypeFromForm = handler< unknown, { selfModel: Writable; systemField: Writable; resultField: Writable; } >((_event, { selfModel, systemField, resultField }) => { const result = resultField.get().trim(); if (!result) return; const system = systemField.get(); const entry: Neurotype = { system, result, source: "self-reported", recordedAt: safeDateNow(), }; selfModel.set(upsertNeurotype(selfModel.get(), entry)); resultField.set(""); }); /** * Remove a neurotype by system (state-reading handler, for per-row binding). */ export const removeNeurotypeBySystem = handler< unknown, { selfModel: Writable; system: NeurotypeSystem } >((_event, { selfModel, system }) => { selfModel.set(withoutNeurotype(selfModel.get(), system)); }); /** * Record a meaning reflection from form fields (state-reading handler). * Looks up the prompt in MEANING_PROMPTS by currentPromptId; if answerField * is non-empty, appends a QAResponse(track:"meaning") and clears answerField. */ export const recordReflectionFromForm = handler< unknown, { selfModel: Writable; currentPromptId: Writable; answerField: Writable; } >((_event, { selfModel, currentPromptId, answerField }) => { const answer = answerField.get().trim(); if (!answer) return; const promptId = currentPromptId.get(); const prompt = MEANING_PROMPTS.find((p) => p.id === promptId); if (!prompt) return; const response: QAResponse = { promptId: prompt.id, prompt: prompt.text, answer, track: "meaning", answeredAt: safeDateNow(), }; selfModel.set(appendResponse(selfModel.get(), response)); answerField.set(""); }); /** * Add a value card from form fields (state-reading handler). * Reads titleField, attendingToField, stanceField, contextTagsField; * if title non-empty, appends a ValueCard and clears all fields. */ export const addValueCardFromForm = handler< unknown, { selfModel: Writable; titleField: Writable; attendingToField: Writable; stanceField: Writable<"descriptive" | "aspirational" | "conflicted">; contextTagsField: Writable; } >(( _event, { selfModel, titleField, attendingToField, stanceField, contextTagsField }, ) => { const title = titleField.get().trim(); if (!title) return; const attendingTo = attendingToField.get().trim() || undefined; const stance = stanceField.get(); const rawTags = contextTagsField.get(); const contextTags = rawTags .split(",") .map((t) => t.trim()) .filter((t) => t.length > 0); const card: ValueCard = { title, attendingTo, stance, contextTags: contextTags.length > 0 ? contextTags : undefined, }; selfModel.set(appendValue(selfModel.get(), card)); titleField.set(""); attendingToField.set(""); // stanceField intentionally retains the last-selected stance (3-way enum, no neutral) contextTagsField.set(""); }); /** * Remove a value card by index (state-reading handler, for per-row binding). */ export const removeValueCardByIndex = handler< unknown, { selfModel: Writable; index: number } >((_event, { selfModel, index }) => { const current = selfModel.get(); selfModel.set({ ...current, values: current.values.filter((_, i) => i !== index), }); }); // ============================================================================ // MAIN PATTERN // ============================================================================ const Self = pattern( ({ selfModel }) => { // `selfModel` is seeded with EMPTY_SELF_MODEL via Default<> when the pattern // owns its cell (home-local, no injection) and may be injected by tests or // parent patterns. Previously this used `injected ?? new Writable(...).for()`, // but a `new Writable(initial)` on the RIGHT of ?? is lowered to a lift whose // value is undefined when uninjected — so the owned cell never seeded and // every capture handler threw on `selfModel.get().neurotypes` (CT-1669). // Local form field cells — neurotype const systemField = new Writable("mbti").for( "systemField", ); const resultField = new Writable("").for("resultField"); // Local form field cells — meaning reflections const currentPromptId = new Writable(MEANING_PROMPTS[0].id).for( "currentPromptId", ); const answerField = new Writable("").for("answerField"); // Local form field cells — value cards const titleField = new Writable("").for("titleField"); const attendingToField = new Writable("").for("attendingToField"); const stanceField = new Writable< "descriptive" | "aspirational" | "conflicted" >("descriptive").for("stanceField"); const contextTagsField = new Writable("").for("contextTagsField"); const neurotypeSystemItems: { label: string; value: NeurotypeSystem }[] = [ { label: "MBTI", value: "mbti" }, { label: "Enneagram", value: "enneagram" }, { label: "Big Five", value: "big5" }, { label: "Custom", value: "custom" }, ]; const promptSelectItems = MEANING_PROMPTS.map((p) => ({ label: p.text.slice(0, 60) + (p.text.length > 60 ? "…" : ""), value: p.id, })); const stanceItems: { label: string; value: "descriptive" | "aspirational" | "conflicted"; }[] = [ { label: "Descriptive (how I already attend)", value: "descriptive" }, { label: "Aspirational (how I want to attend)", value: "aspirational" }, { label: "Conflicted (unclear or in tension)", value: "conflicted" }, ]; return { [NAME]: "My Self", // [UI] must be a static VNode — not wrapped in computed(). [UI]: (

Self Model

Your private self-model. Never shared.

Record Neurotype

Record

Neurotypes

{selfModel.key("neurotypes").map((n) => ( {n.system} {n.result} ))}
{ /* ================================================================ MEANING & VALUES (CT-1674) ================================================================ */ }

Meaning & Values

Reflective prompts and your value cards — what you attend to that matters.

{/* -- Reflect section -- */}

Reflect

{computed(() => MEANING_PROMPTS.find((x) => x.id === currentPromptId.get()) ?.text ?? "" )}

Record reflection

Recorded reflections

{selfModel.key("responses").map((r) => ifElse( computed(() => r.track === "meaning"), {r.prompt} {r.answer} , null, ) )}
{/* -- Your values section -- */}

Add a value

Add value

Your values

{selfModel.key("values").map((v, index) => ( {v.title} {v.attendingTo ? ( Attending to: {v.attendingTo} ) : } {v.stance ? ( {v.stance} ) : } ))}
), selfModel, // Bind each handler to this instance's selfModel cell. recordNeurotype: recordNeurotype({ selfModel }), addValueCard: addValueCard({ selfModel }), recordResponse: recordResponse({ selfModel }), removeNeurotype: removeNeurotype({ selfModel }), removeValueCard: removeValueCard({ selfModel }), }; }, ); export default Self;