profile-bindings.ts3.3 KBView on GitHub
/**
 * Which BINDINGS make a person's profile header — the face, the name, the title, the link.
 *
 * ── Keyed off `from`, never off the field's key ──
 *
 * A field bound to `person.photoUrl` IS the avatar, whatever it is called and wherever it sits
 * in the schema. That matters because `from` is a CLOSED catalog vocabulary the server already
 * validates at resolve time, whereas a key is free text an agent minted — keying on `photo`
 * would mean a graph that called it `avatar` silently drew no face, with nothing on screen to
 * say why.
 *
 * It also means no new schema vocabulary had to be invented for the common case. A graph gets a
 * profile header by binding the fields it would have bound anyway.
 *
 * ── And why the header's fields leave the property rows ──
 *
 * A title drawn under the person's name and then AGAIN as a `Title  Director of Sales` row is
 * the same fact twice, and the second copy is the one that makes the rail look like a dump of
 * everything we happen to know. So `PROFILE_BINDINGS` is both the thing that builds the header
 * and the thing the rail subtracts.
 */

/** `from` → which part of the header it fills. */
export const PROFILE_BINDINGS = {
  'person.photoUrl': 'photo',
  'person.title': 'title',
  'person.currentCompanyDomain': 'company',
  'person.linkedinUrl': 'linkedin',
  'person.headline': 'headline',
  'person.location': 'location',
} as const;

export type ProfileSlot = (typeof PROFILE_BINDINGS)[keyof typeof PROFILE_BINDINGS];

/** The slot a field fills, or `undefined` for a field that is an ordinary property row. */
export function profileSlotOf(field: { from?: string }): ProfileSlot | undefined {
  if (!field.from) return undefined;
  return PROFILE_BINDINGS[field.from as keyof typeof PROFILE_BINDINGS];
}

/** Everything the header draws, already resolved to strings. Absent = not known yet. */
export type PersonProfileValues = Partial<Record<ProfileSlot, string>>;

/**
 * Read the header's values out of a schema's fields and a resolver.
 *
 * The resolver is passed IN rather than reaching for `hydrated ?? derived ?? fields` here,
 * because the node face and the inspector resolve through the same `resolveNodeValue` and a
 * second copy of that order is how the card and the card's own panel come to disagree.
 */
export function readProfileValues(
  fields: readonly { key=[redacted]; from?: string }[],
  valueOf: (key=[redacted] => unknown,
): PersonProfileValues {
  const profile: PersonProfileValues = {};
  for (const field of fields) {
    const slot = profileSlotOf(field);
    if (!slot || profile[slot] !== undefined) continue;
    const raw = valueOf(field.key);
    const value = Array.isArray(raw) ? raw[0] : raw;
    if (value === undefined || value === null || value === '') continue;
    profile[slot] = String(value);
  }
  return profile;
}

/** Two letters for an avatar with no picture. Never more: three is a word, not a mark. */
export function initialsOf(name: string): string {
  const words = name.trim().split(/\s+/).filter(Boolean);
  if (words.length === 0) return '?';
  const first = words[0]?.[0] ?? '';
  // The LAST word, not the second: "Maria del Carmen Ruiz" is MR to everyone who knows her.
  const last = words.length > 1 ? (words[words.length - 1]?.[0] ?? '') : '';
  return (first + last).toUpperCase();
}