/// /** * Person - Canonical base pattern for person data. * * This pattern serves as the schelling point for person-like data. * It implements the PersonLike interface ({ firstName, lastName }) and adds * optional contact fields (email, phone) plus rich detail fields * (notes, tags, addresses, socialProfiles). * * Sub-types can extend Person or PersonLike to add domain-specific fields: * - FamilyMember adds: relationship, birthday, dietary restrictions * - Colleague adds: company, department, title * - Contact adds: multiple phones, addresses, social profiles */ import { computed, type Default, handler, NAME, pattern, UI, type VNode, Writable, } from "commontools"; import type { ContactPiece, Person, PersonLike } from "./contact-types.tsx"; // Re-export for backwards compatibility export type { ContactPiece, Person, PersonLike } from "./contact-types.tsx"; // ============================================================================ // Constants // ============================================================================ const SOCIAL_PLATFORM_OPTIONS = [ { value: "", label: "Select..." }, { value: "LinkedIn", label: "LinkedIn" }, { value: "Twitter", label: "Twitter" }, { value: "GitHub", label: "GitHub" }, { value: "Instagram", label: "Instagram" }, { value: "Facebook", label: "Facebook" }, { value: "Website", label: "Website" }, { value: "Other", label: "Other" }, ]; const MONTH_OPTIONS = [ { value: "0", label: "Month..." }, { value: "1", label: "January" }, { value: "2", label: "February" }, { value: "3", label: "March" }, { value: "4", label: "April" }, { value: "5", label: "May" }, { value: "6", label: "June" }, { value: "7", label: "July" }, { value: "8", label: "August" }, { value: "9", label: "September" }, { value: "10", label: "October" }, { value: "11", label: "November" }, { value: "12", label: "December" }, ]; const DAY_OPTIONS = [ { value: "0", label: "Day..." }, ...Array.from({ length: 31 }, (_, i) => ({ value: String(i + 1), label: String(i + 1), })), ]; const MONTH_NAMES = [ "", "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec", ]; const ADDRESS_LABEL_OPTIONS = [ { value: "", label: "Select..." }, { value: "Home", label: "Home" }, { value: "Work", label: "Work" }, { value: "Other", label: "Other" }, ]; // ============================================================================ // Handlers // ============================================================================ const selectSameAs = handler< { detail: { data?: PersonLike } }, { person: Writable; showPicker: Writable } >(({ detail }, { person, showPicker }) => { const linked = detail?.data; if (!linked) return; const current = person.get(); person.set({ ...current, sameAs: linked }); showPicker.set(false); }); const clearSameAs = handler }>( (_event, { person }) => { const current = person.get(); person.set({ ...current, sameAs: undefined }); }, ); const togglePicker = handler }>( (_event, { showPicker }) => { showPicker.set(!showPicker.get()); }, ); const toggleSection = handler }>( (_event, { section }) => { section.set(!section.get()); }, ); const updateTags = handler< { detail: { tags: string[] } }, { person: Writable } >(({ detail }, { person }) => { const current = person.get(); person.set({ ...current, tags: detail?.tags ?? [] }); }); const addAddress = handler }>( (_event, { person }) => { const current = person.get(); const addresses = [...(current.addresses || [])]; addresses.push({ label: "", street: "", city: "", state: "", zip: "", country: "", }); person.set({ ...current, addresses }); }, ); const removeAddress = handler< unknown, { person: Writable; index: number } >((_event, { person, index }) => { const current = person.get(); const addresses = [...(current.addresses || [])]; addresses.splice(index, 1); person.set({ ...current, addresses }); }); const addSocialProfile = handler }>( (_event, { person }) => { const current = person.get(); const socialProfiles = [...(current.socialProfiles || [])]; socialProfiles.push({ platform: "", url: "" }); person.set({ ...current, socialProfiles }); }, ); const removeSocialProfile = handler< unknown, { person: Writable; index: number } >((_event, { person, index }) => { const current = person.get(); const socialProfiles = [...(current.socialProfiles || [])]; socialProfiles.splice(index, 1); person.set({ ...current, socialProfiles }); }); // ============================================================================ // Input/Output Schemas // ============================================================================ interface Input { person: Writable< Default< Person, { firstName: ""; lastName: ""; middleName: ""; nickname: ""; prefix: ""; suffix: ""; pronouns: ""; birthday: { month: 0; day: 0; year: 0 }; photo: ""; email: ""; phone: ""; notes: ""; tags: []; addresses: []; socialProfiles: []; } > >; // Optional: reactive source of sibling contacts for sameAs linking. sameAs?: Writable; } interface Output { [NAME]: string; [UI]: VNode; person: Person; } // ============================================================================ // UI Helpers // ============================================================================ function sectionHeader( label: string, expanded: Writable, count?: () => number, ) { return ( ); } // ============================================================================ // Pattern // ============================================================================ export default pattern(({ person, sameAs }) => { // Computed display name from first + last name const displayName = computed(() => { const first = person.key("firstName").get(); const last = person.key("lastName").get(); if (first && last) return `${first} ${last}`; if (first) return first; if (last) return last; return "Person"; }); // Computed: current sameAs link display const sameAsDisplay = computed(() => { const linked = person.key("sameAs").get(); if (!linked) return null; const first = linked.firstName || ""; const last = linked.lastName || ""; if (first && last) return `${first} ${last}`; if (first) return first; if (last) return last; return "Unknown"; }); // State: whether the sameAs picker is expanded const showPicker = Writable.of(false); // Section expansion state const showNameDetails = Writable.of(false); const showContactInfo = Writable.of(true); const showAddresses = Writable.of(false); const showSocial = Writable.of(false); const showNotes = Writable.of(false); // Computed: autocomplete items from reactive sibling source, filtering self const sameAsItems = computed(() => { if (!sameAs) return []; const all = sameAs.get(); if (!all || all.length === 0) return []; const selfFirst = person.key("firstName").get(); const selfLast = person.key("lastName").get(); const hasSelfName = Boolean(selfFirst || selfLast); const result: Array<{ value: string; label: string; data: PersonLike }> = []; for (const c of all) { const p = c.person ?? c.member; if (!p) continue; // Only filter by name if this contact actually has a name set if ( hasSelfName && p.firstName === selfFirst && p.lastName === selfLast ) { continue; } const label = p.firstName && p.lastName ? `${p.firstName} ${p.lastName}` : p.firstName || p.lastName || "Person"; result.push({ value: label, label, data: p }); } return result; }); const hasSameAsCandidates = computed(() => sameAsItems.length > 0); return { [NAME]: displayName, [UI]: ( {/* Basic Info - always visible */} {/* Pronouns */} {/* Tags */} {/* Name Details Section */}
{sectionHeader("Name Details", showNameDetails)} {computed(() => { if (!showNameDetails.get()) return null; return ( ); })}
{/* Birthday Section */} {computed(() => { const month = person.key("birthday").key("month").get() || 0; const day = person.key("birthday").key("day").get() || 0; const year = person.key("birthday").key("year").get() || 0; if (month === 0 || day === 0) return null; const monthName = MONTH_NAMES[month] || ""; const display = year > 0 ? `${monthName} ${day}, ${year}` : `${monthName} ${day}`; return ( {display} ); })} {/* Photo URL */} { /* Contact Info Section * WORKAROUND: Each computed() must be the sole reactive child of its * parent element. Multiple computed() siblings break rendering. * Wrap each sectionHeader+computed pair in a
. */ }
{sectionHeader("Contact Info", showContactInfo)} {computed(() => { if (!showContactInfo.get()) return null; return ( ); })}
{/* Addresses Section */}
{sectionHeader( "Addresses", showAddresses, () => (person.key("addresses").get() || []).length, )} {computed(() => { if (!showAddresses.get()) return null; const addresses = person.key("addresses").get() || []; return ( {addresses.map((_addr, i) => ( × ))} + Add Address ); })}
{/* Social Profiles Section */}
{sectionHeader( "Social Profiles", showSocial, () => (person.key("socialProfiles").get() || []).length, )} {computed(() => { if (!showSocial.get()) return null; const profiles = person.key("socialProfiles").get() || []; return ( {profiles.map((_profile, i) => ( × ))} + Add Profile ); })}
{/* Notes Section */}
{sectionHeader("Notes", showNotes)} {computed(() => { if (!showNotes.get()) return null; return ( ); })}
{/* sameAs Section - collapsed by default, only if candidates exist */}
{computed(() => { if (!hasSameAsCandidates) return null; const linkedName = sameAsDisplay; // If linked, show compact display if (linkedName) { return (
Same as: {linkedName} ×
); } // If picker is open, show autocomplete if (showPicker.get()) { return ( ); } // Collapsed: small link to expand return ( Link to another contact... ); })}
), person, }; });