/// /** * Profile - A blackboard for personal data coordination. * * This pattern serves as a Schelling point where multiple patterns * can read and write personal information (à la Minsky's blackboard). * Entities stored here can later be "popped out" to dedicated patterns * (person.tsx, vehicle.tsx, etc.) without changing the data structure. * * Usage from other patterns: * const profile = wish({ query: "#profile" }); * profile?.memberships.push({ program: "Hilton Honors", memberNumber: "12345" }); */ import { action, computed, type Default, NAME, pattern, UI, type VNode, Writable, } from "commontools"; // ============================================================================ // ATOMIC TYPES - Keep simple, use string for editable fields // ============================================================================ interface Birthday { month: string; // "1"-"12" or "" day: string; // "1"-"31" or "" year: string; // "YYYY" or "" } interface Phone { label: string; // "Mobile", "Home", "Work" number: string; } interface Email { label: string; address: string; } interface Address { label: string; // "Home", "Work", "Mailing" street: string; city: string; state: string; zip: string; country: string; } interface School { name: string; gradeLevel: string; teacher: string; } // ============================================================================ // ENTITY TYPES // ============================================================================ export interface Person { name: string; nickname: string; birthday: Birthday; relationship: string; phones: Phone[]; emails: Email[]; addresses: Address[]; school: School; notes: string; } export interface Vehicle { make: string; model: string; year: string; licensePlate: string; vin: string; notes: string; } export interface Membership { program: string; memberNumber: string; notes: string; } export interface Bank { name: string; hasCheckingAccount: Default; hasCreditCard: Default; notes: string; } export interface Employment { employer: string; title: string; street: string; city: string; state: string; notes: string; } // ============================================================================ // LEARNED TYPES - Inferred from user behavior (populated by home.tsx) // ============================================================================ /** A fact learned about the user from their behavior */ export interface Fact { content: string; // "User likes cooking", "User has kids" confidence: number; // 0-1, higher = more certain source: string; // e.g., "journal:1234567890" or "user:direct" timestamp: number; } /** A preference inferred from user behavior */ export interface Preference { key: string; // e.g., "cooking_style", "communication_tone" value: string; confidence: number; source: string; } /** A question to ask the user for clarification */ export interface Question { id: string; question: string; category: string; // "preferences", "personal", "context" priority: number; // Higher = ask sooner options?: string[]; // For multiple choice status: "pending" | "asked" | "answered" | "skipped"; answer?: string; askedAt?: number; answeredAt?: number; } /** Section containing all learned/inferred data */ export interface LearnedSection { facts: Fact[]; preferences: Preference[]; openQuestions: Question[]; personas: string[]; // "busy parent", "home cook", "techie" lastJournalProcessed: number; // Timestamp of last processed journal entry summary: string; // User-editable text summary summaryVersion: number; // Tracks when summary was last auto-generated } /** Default empty learned section for initialization */ export const EMPTY_LEARNED: LearnedSection = { facts: [], preferences: [], openQuestions: [], personas: [], lastJournalProcessed: 0, summary: "", summaryVersion: 0, }; // ============================================================================ // DEFAULT VALUES (for Default<> type parameters) // ============================================================================ const EMPTY_PERSON: Person = { name: "", nickname: "", birthday: { month: "", day: "", year: "" }, relationship: "", phones: [], emails: [], addresses: [], school: { name: "", gradeLevel: "", teacher: "" }, notes: "", }; const EMPTY_EMPLOYMENT: Employment = { employer: "", title: "", street: "", city: "", state: "", notes: "", }; // ============================================================================ // PROFILE INPUT/OUTPUT SCHEMAS // ============================================================================ interface ProfileInput { self?: Writable>; partner?: Writable>; children?: Writable>; parents?: Writable>; inlaws?: Writable>; addresses?: Writable>; vehicles?: Writable>; memberships?: Writable>; banks?: Writable>; employment?: Writable>; notes?: Writable>; learned?: Writable>; } /** Profile blackboard for personal data coordination. #profile */ export interface Output { [NAME]: string; [UI]: VNode; self: Person; partner: Person; children: Person[]; parents: Person[]; inlaws: Person[]; addresses: Address[]; vehicles: Vehicle[]; memberships: Membership[]; banks: Bank[]; employment: Employment; notes: string; learned: LearnedSection; } /** @deprecated Use Output instead - this alias exists for backwards compatibility */ export type ProfileOutput = Output; // ============================================================================ // CONSTANTS // ============================================================================ const ADDRESS_LABELS = [ { value: "Home", label: "Home" }, { value: "Work", label: "Work" }, { value: "Mailing", label: "Mailing" }, { value: "Other", label: "Other" }, ]; const RELATIONSHIP_OPTIONS = [ { value: "", label: "Select..." }, { value: "spouse", label: "Spouse" }, { value: "partner", label: "Partner" }, { value: "child", label: "Child" }, { value: "parent", label: "Parent" }, { value: "sibling", label: "Sibling" }, { value: "in-law", label: "In-law" }, { value: "friend", label: "Friend" }, { value: "other", label: "Other" }, ]; // ============================================================================ // HELPER: Create empty entities for push operations // ============================================================================ const newPerson = (): Person => ({ name: "", nickname: "", birthday: { month: "", day: "", year: "" }, relationship: "", phones: [], emails: [], addresses: [], school: { name: "", gradeLevel: "", teacher: "" }, notes: "", }); const newAddress = (): Address => ({ label: "Home", street: "", city: "", state: "", zip: "", country: "", }); const newVehicle = (): Vehicle => ({ make: "", model: "", year: "", licensePlate: "", vin: "", notes: "", }); const newMembership = (): Membership => ({ program: "", memberNumber: "", notes: "", }); const newBank = (): Bank => ({ name: "", hasCheckingAccount: false, hasCreditCard: false, notes: "", }); // ============================================================================ // STYLES // ============================================================================ const sectionHeaderStyle = { display: "flex", alignItems: "center", gap: "8px", width: "100%", padding: "12px 16px", background: "var(--ct-color-bg-secondary, #f9fafb)", border: "1px solid var(--ct-color-border, #e5e5e7)", borderRadius: "8px", cursor: "pointer", fontSize: "15px", fontWeight: "600", }; const sectionContentStyle = { padding: "12px", background: "var(--ct-color-bg, white)", border: "1px solid var(--ct-color-border, #e5e5e7)", borderRadius: "8px", }; const labelStyle = { fontSize: "11px", color: "#6b7280" }; const removeButtonStyle = { padding: "8px", background: "none", border: "none", cursor: "pointer", color: "#ef4444", }; const bigAddButtonStyle = { padding: "12px 24px", background: "var(--ct-color-bg-secondary, #f3f4f6)", border: "1px dashed var(--ct-color-border, #e5e5e7)", borderRadius: "8px", cursor: "pointer", fontSize: "14px", }; // ============================================================================ // MAIN PATTERN // ============================================================================ const Profile = pattern( ({ self, partner, children, parents, inlaws, addresses, vehicles, memberships, banks, employment, notes, learned, }) => { // Section expanded states const selfExpanded = Writable.of(true); const partnerExpanded = Writable.of(false); const childrenExpanded = Writable.of(false); const parentsExpanded = Writable.of(false); const inlawsExpanded = Writable.of(false); const addressesExpanded = Writable.of(false); const vehiclesExpanded = Writable.of(false); const membershipsExpanded = Writable.of(false); const banksExpanded = Writable.of(false); const employmentExpanded = Writable.of(false); const learnedExpanded = Writable.of(false); // Actions for adding items const addChild = action(() => children.push(newPerson())); const addParent = action(() => parents.push(newPerson())); const addInlaw = action(() => inlaws.push(newPerson())); const addAddress = action(() => addresses.push(newAddress())); const addVehicle = action(() => vehicles.push(newVehicle())); const addMembership = action(() => memberships.push(newMembership())); const addBank = action(() => banks.push(newBank())); // Computed display name const displayName = computed(() => { const name = self.key("name").get(); return name ? `${name}'s Profile` : "My Profile"; }); // Note: Journal watching and profile learning is handled by home.tsx // This pattern is purely UI - it displays the learned cell passed to it return { [NAME]: computed(() => `👤 ${displayName}`), [UI]: (

{displayName}

Personal data blackboard - tag with #profile for discovery
{/* === SELF === */}
selfExpanded.get() ? "block" : "none" ), ...sectionContentStyle, }} >
{/* === PARTNER === */}
partnerExpanded.get() ? "block" : "none" ), ...sectionContentStyle, }} >
{/* === CHILDREN === */}
childrenExpanded.get() ? "block" : "none" ), ...sectionContentStyle, }} > {children.map((child) => ( ))}
{/* === PARENTS === */}
parentsExpanded.get() ? "block" : "none" ), ...sectionContentStyle, }} > {parents.map((person) => ( ))}
{/* === IN-LAWS === */}
inlawsExpanded.get() ? "block" : "none" ), ...sectionContentStyle, }} > {inlaws.map((person) => ( ))}
{/* === ADDRESSES === */}
addressesExpanded.get() ? "block" : "none" ), ...sectionContentStyle, }} > {addresses.map((addr) => ( ))}
{/* === VEHICLES === */}
vehiclesExpanded.get() ? "block" : "none" ), ...sectionContentStyle, }} > {vehicles.map((v) => ( ))}
{/* === MEMBERSHIPS === */}
membershipsExpanded.get() ? "block" : "none" ), ...sectionContentStyle, }} > {memberships.map((m) => ( ))}
{/* === BANKS === */}
banksExpanded.get() ? "block" : "none" ), ...sectionContentStyle, }} > {banks.map((b) => ( Checking Account Credit Card ))}
{/* === EMPLOYMENT === */}
employmentExpanded.get() ? "block" : "none" ), ...sectionContentStyle, }} >
{/* === LEARNED === */}
learnedExpanded.get() ? "block" : "none" ), ...sectionContentStyle, }} > {/* Personas */} {computed(() => learned.key("personas").get().length > 0) && ( {learned.key("personas").map((persona) => ( {persona} ))} )} {/* Facts Table */} {computed(() => learned.key("facts").get().length === 0 ) && (

No facts learned yet. Facts will appear here as you use the app.

)} {computed(() => learned.key("facts").get().length > 0) && (
{learned.key("facts").map((fact) => ( ))}
Fact Conf. Source When
{fact.content} fact.confidence > 0.8 ? "#dcfce7" : fact.confidence > 0.5 ? "#fef9c3" : "#fee2e2" ), color: computed(() => fact.confidence > 0.8 ? "#166534" : fact.confidence > 0.5 ? "#854d0e" : "#991b1b" ), borderRadius: "4px", fontSize: "12px", fontWeight: "500", }} > {computed( () => `${ Math.round(fact.confidence * 100) }%`, )} {fact.source} {computed(() => { const ts = fact.timestamp; if (!ts) return "-"; const d = new Date(ts); return `${ d.getMonth() + 1 }/${d.getDate()}`; })}
)}
{/* Preferences Table */} {computed(() => learned.key("preferences").get().length > 0 ) && (
{learned.key("preferences").map((pref) => ( ))}
Key Value Conf.
{pref.key} {pref.value} pref.confidence > 0.8 ? "#dcfce7" : pref.confidence > 0.5 ? "#fef9c3" : "#fee2e2" ), color: computed(() => pref.confidence > 0.8 ? "#166534" : pref.confidence > 0.5 ? "#854d0e" : "#991b1b" ), borderRadius: "4px", fontSize: "12px", fontWeight: "500", }} > {computed( () => `${ Math.round(pref.confidence * 100) }%`, )}
)} {/* Open Questions - Plain Text */} {computed(() => { const questions = learned.key("openQuestions").get(); const pending = questions.filter( (q) => q.status === "pending", ); return pending.length > 0; }) && ( {learned.key("openQuestions").map((q) => (
q.status === "pending" ? "block" : "none" ), }} > [{q.category}] {" "} {q.question} {computed(() => q.options && q.options.length > 0 ? ` (${q.options.join(" | ")})` : "" )}
))}
)}
{/* === NOTES === */}
), // Pass through all data self, partner, children, parents, inlaws, addresses, vehicles, memberships, banks, employment, notes, learned, }; }, ); export default Profile;