top-rep-playbook.ts48.4 KBView on GitHub
/**
 * Mock "Top rep playbook" — a set of linked Cedar Docs.
 *
 *   Main doc (user/files/top-rep-playbook)
 *     ## Org-wide conversion rate
 *     ## Per rep conversion rate    — cards link to each rep's subdocument
 *     ## Playbook
 *       ### Follow-up               — meaningful response/follow-up stats by outcome
 *       ### Stage 1–4               — common patterns · objections · messaging ·
 *                                     features asked · pain points (all with win rates)
 *
 *   Per-rep subdocs (user/files/users/<slug>) — that rep vs the team average + playbook
 *   Per-objection subdocs (user/files/playbook/<stage>/objections/<slug>) — talk-track stats
 *
 * Every source is mock-backed, so the whole set renders with no database access.
 * `playbookDocuments[0]` is the main doc; the rest are linked subdocuments.
 */

export interface PlaybookDocument {
  path: string;
  title: string;
  markdown: string;
}

// ── helpers ───────────────────────────────────────────────────────────────────

let specSeq = 0;
function fence(spec: Record<string, unknown>): string {
  spec.spec_version = '2.0';
  spec.id = (spec.id as string) ?? `mock_${specSeq++}`;
  return '```dashboard\n' + JSON.stringify(spec, null, 2) + '\n```';
}

const USERS_BASE = 'user/files/users';
const OBJ_BASE = 'user/files/playbook';
const MAIN_PATH = 'user/files/top-rep-playbook';

const slug = (s: string) =>
  s.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 48);

const hours = (h: number) => Math.round(h * 3600);
const days = (d: number) => Math.round(d * 86400);

// ── reps ───────────────────────────────────────────────────────────────────────

const STAGE_LABELS = ['Discovery', 'Demo', 'Negot.', 'Close'];
const steps = (counts: number[]) => counts.map((count, i) => ({ label: STAGE_LABELS[i], count }));

interface PlaybookStageCard {
  num: string;
  title: string;
  goal?: string;
  objection?: string;
  reply?: string;
  tactic?: string;
  deliverable?: string;
}

interface Rep {
  slug: string;
  name: string;
  initials: string;
  label: string;
  top: boolean;
  steps: { label: string; count: number }[];
  demo: number;
  negotiation: number;
  close: number;
  winRate: number;
  firstResponseSec: number;
  followupSec: number;
  replyRate: number;
  stageWin: { stage: string; win: number }[];
  playbook: PlaybookStageCard[];
}

const willemPlaybook: PlaybookStageCard[] = [
  {
    num: 'S1',
    title: 'Discovery',
    goal: 'Prospect names one use case + success metric in writing',
    objection: "Can you send the deck and pricing? I'll loop people in.",
    reply:
      'Happy to send both. Before I do — what one outcome would you need to see in 90 days to expand beyond a pilot?',
    tactic: 'Never send pricing before a written use case. Anchor on a success metric.',
    deliverable: 'Written use case + 6 scoping answers',
  },
  {
    num: 'S2',
    title: 'Demo',
    goal: "Live aha on the prospect's own pipeline + named champion",
    objection: "We're already evaluating Gong and Clari.",
    reply:
      'Totally fair — Gong wins on call coaching, Clari on forecast hygiene. We win on the writing + follow-up layer. Want a side-by-side on your top 5 open deals?',
    tactic: "Acknowledge the competitor's strength, draw the boundary, propose a head-to-head.",
    deliverable: 'Head-to-head demo scheduled on real data',
  },
  {
    num: 'S3',
    title: 'Negotiation',
    goal: 'Term sheet with a clear concession ladder',
    objection: 'Procurement caps us at 12-month terms.',
    reply: '12 months at list, or 24 months at −22% with a 90-day opt-out. Commit by Friday to hold 22%.',
    tactic: 'Trade term length for discount — never discount without a quid pro quo.',
    deliverable: '12mo @ list / 24mo @ −22% (expires Fri)',
  },
  {
    num: 'S4',
    title: 'Close',
    goal: 'Countersigned MSA + order form within 48h of verbal yes',
    objection: 'Legal says liability + SOC2 redlines will take 3 weeks.',
    reply:
      "We've pre-cleared liability at 12× annual and ship SOC2 Type II yearly — a 30-min legal-to-legal compresses this to days.",
    tactic: 'Pre-empt the 3 standard redlines (liability, IP, DPA). MSA + OF same day.',
    deliverable: 'MSA · OF · NET 30 · liability 12× · SOC2 Type II',
  },
];

const genericPlaybook = (name: string): PlaybookStageCard[] => [
  {
    num: 'S1',
    title: 'Discovery',
    goal: 'Qualify budget + timeline',
    objection: 'Just send me some info.',
    reply: 'Sure — what is the one metric that would make this a priority this quarter?',
    tactic: `${name} sends collateral early; anchoring on a written success metric first lifts conversion.`,
    deliverable: 'Use case captured',
  },
  {
    num: 'S2',
    title: 'Demo',
    goal: 'Show value on prospect data',
    objection: 'Looks similar to what we have.',
    reply: 'Where it differs is the follow-up layer — want me to run it on your last 5 stalled deals?',
    tactic: 'Differentiate on the follow-up + writing layer, not feature parity.',
    deliverable: 'Tailored demo booked',
  },
  {
    num: 'S3',
    title: 'Negotiation',
    goal: 'Hold margin',
    objection: 'Your competitor is cheaper.',
    reply: 'Happy to compare total cost — what is driving the price comparison?',
    tactic: 'Trade concessions for term length; avoid unilateral discounts.',
    deliverable: 'Term sheet sent',
  },
];

const reps: Rep[] = [
  {
    slug: 'willem',
    name: 'Willem',
    initials: 'WE',
    label: 'Top rep',
    top: true,
    steps: steps([100, 82, 53, 38]),
    demo: 82,
    negotiation: 53,
    close: 38,
    winRate: 38,
    firstResponseSec: hours(1.2),
    followupSec: days(1.8),
    replyRate: 71,
    stageWin: [
      { stage: 'Discovery', win: 41 },
      { stage: 'Demo', win: 46 },
      { stage: 'Negotiation', win: 58 },
      { stage: 'Close', win: 72 },
    ],
    playbook: willemPlaybook,
  },
  {
    slug: 'sarah',
    name: 'Sarah',
    initials: 'SK',
    label: 'Account Exec',
    top: false,
    steps: steps([100, 74, 38, 21]),
    demo: 74,
    negotiation: 38,
    close: 21,
    winRate: 21,
    firstResponseSec: hours(3.6),
    followupSec: days(3.1),
    replyRate: 52,
    stageWin: [
      { stage: 'Discovery', win: 28 },
      { stage: 'Demo', win: 31 },
      { stage: 'Negotiation', win: 34 },
      { stage: 'Close', win: 49 },
    ],
    playbook: genericPlaybook('Sarah'),
  },
  {
    slug: 'marcus',
    name: 'Marcus',
    initials: 'MR',
    label: 'Account Exec',
    top: false,
    steps: steps([100, 61, 23, 10]),
    demo: 61,
    negotiation: 23,
    close: 10,
    winRate: 10,
    firstResponseSec: hours(6.2),
    followupSec: days(5.4),
    replyRate: 38,
    stageWin: [
      { stage: 'Discovery', win: 19 },
      { stage: 'Demo', win: 21 },
      { stage: 'Negotiation', win: 23 },
      { stage: 'Close', win: 38 },
    ],
    playbook: genericPlaybook('Marcus'),
  },
  {
    slug: 'lily',
    name: 'Lily',
    initials: 'LP',
    label: 'Account Exec',
    top: false,
    steps: steps([100, 68, 31, 15]),
    demo: 68,
    negotiation: 31,
    close: 15,
    winRate: 15,
    firstResponseSec: hours(4.1),
    followupSec: days(2.6),
    replyRate: 47,
    stageWin: [
      { stage: 'Discovery', win: 24 },
      { stage: 'Demo', win: 27 },
      { stage: 'Negotiation', win: 31 },
      { stage: 'Close', win: 55 },
    ],
    playbook: genericPlaybook('Lily'),
  },
];

const mean = (xs: number[]) => Math.round(xs.reduce((a, b) => a + b, 0) / xs.length);
const meanF = (xs: number[]) => xs.reduce((a, b) => a + b, 0) / xs.length;
const teamRow = {
  demo: mean(reps.map((r) => r.demo)),
  negotiation: mean(reps.map((r) => r.negotiation)),
  close: mean(reps.map((r) => r.close)),
  winRate: mean(reps.map((r) => r.winRate)),
};

/** Human duration from seconds (mirrors the renderer's `duration` format). */
function dur(sec: number): string {
  const d = sec / 86400;
  if (d >= 1) return `${Math.round(d * 10) / 10}d`;
  const h = sec / 3600;
  if (h >= 1) return `${Math.round(h * 10) / 10}h`;
  const m = sec / 60;
  if (m >= 1) return `${Math.round(m)}m`;
  return `${Math.round(sec)}s`;
}

// Call intelligence per rep (mock): cadence, talk:listen ratio, call length,
// discovery questions asked, and rate of calls that end with a booked next step.
interface CallStats {
  perWeek: number;
  talkRatio: number; // % of call the rep is talking
  avgCallMin: number;
  discoveryQs: number; // avg discovery questions asked
  nextStepRate: number; // % of calls ending with a booked next step
  monologueSec: number; // longest uninterrupted rep monologue
}
const callStats: Record<string, CallStats> = {
  willem: { perWeek: 14, talkRatio: 43, avgCallMin: 32, discoveryQs: 11, nextStepRate: 88, monologueSec: 95 },
  sarah: { perWeek: 11, talkRatio: 58, avgCallMin: 27, discoveryQs: 7, nextStepRate: 64, monologueSec: 150 },
  marcus: { perWeek: 9, talkRatio: 67, avgCallMin: 24, discoveryQs: 4, nextStepRate: 41, monologueSec: 220 },
  lily: { perWeek: 12, talkRatio: 51, avgCallMin: 29, discoveryQs: 8, nextStepRate: 72, monologueSec: 130 },
};
const teamCall = {
  perWeek: Math.round(meanF(reps.map((r) => callStats[r.slug].perWeek))),
  talkRatio: Math.round(meanF(reps.map((r) => callStats[r.slug].talkRatio))),
  avgCallMin: Math.round(meanF(reps.map((r) => callStats[r.slug].avgCallMin))),
  discoveryQs: Math.round(meanF(reps.map((r) => callStats[r.slug].discoveryQs))),
  nextStepRate: Math.round(meanF(reps.map((r) => callStats[r.slug].nextStepRate))),
  monologueSec: Math.round(meanF(reps.map((r) => callStats[r.slug].monologueSec))),
};

// Follow-up content that works for each rep (mock).
const followupContent: Record<string, { template: string; replyRate: number; win: number }[]> = {
  willem: [
    { template: 'Recap + single CTA', replyRate: 74, win: 41 },
    { template: 'Mutual action plan', replyRate: 66, win: 37 },
    { template: 'Multi-thread intro to exec', replyRate: 58, win: 33 },
  ],
  sarah: [
    { template: 'Recap + single CTA', replyRate: 55, win: 24 },
    { template: '"Still interested?" nudge', replyRate: 38, win: 17 },
  ],
  marcus: [
    { template: 'Re-send deck, no CTA', replyRate: 24, win: 9 },
    { template: 'Generic check-in', replyRate: 21, win: 8 },
  ],
  lily: [
    { template: 'Recap + next-step proposal', replyRate: 61, win: 28 },
    { template: 'Case-study share', replyRate: 49, win: 22 },
  ],
};
const teamFollow = {
  firstResponseSec: Math.round(meanF(reps.map((r) => r.firstResponseSec))),
  followupSec: Math.round(meanF(reps.map((r) => r.followupSec))),
  replyRate: Math.round(meanF(reps.map((r) => r.replyRate))),
};

// Pooled org funnel (sum across reps — 100 deals each).
const orgFunnel = STAGE_LABELS.map((label, i) => ({
  label,
  count: reps.reduce((sum, r) => sum + r.steps[i].count, 0),
}));
const orgWinRate = teamRow.winRate;

// ── stage data model ─────────────────────────────────────────────────────────

interface TalkTrack {
  track: string;
  rep: string;
  uses: number;
  toNext: number;
  win: number;
}
interface Objection {
  objection: string;
  bestReply: string;
  toNext: number;
  /** Real win rate when this objection appears (from the org's objection wiki). */
  winPct: number;
  /** How many deals this objection has shown up in. */
  mentions: number;
  talkTracks: TalkTrack[];
}
interface Stage {
  n: number;
  id: string;
  name: string;
  goal: string;
  objections: Objection[];
}

// Objections are taken from the org's real objection wiki (organisation/wiki-legacy/objections),
// with their actual win rates and mention counts. Talk tracks + best replies are grounded in
// Cedar's value props: "Cursor for GTM" — proactive, full deal context, playbook-controlled,
// reads/writes your existing CRM + Gmail, learns from your top reps.
const stages: Stage[] = [
  {
    n: 1,
    id: 'stage1',
    name: 'Discovery',
    goal: 'Prospect names one use case + success metric, and sees why Cedar (not a build) is the path.',
    objections: [
      {
        objection: 'Build vs Buy — "we build our GTM tools in-house"',
        bestReply:
          'Teams that build this underestimate the upkeep. Cedar ships the playbook engine + self-improving data flywheel out of the box and gets smarter from your reps’ own calls. What would your eng team have to deprioritize to build and maintain follow-up + CRM automation?',
        toNext: 38,
        winPct: 25,
        mentions: 4,
        talkTracks: [
          { track: 'Reframe to maintenance + opportunity cost', rep: 'Willem', uses: 14, toNext: 42, win: 31 },
          { track: 'Show the self-improving data flywheel', rep: 'Willem', uses: 11, toNext: 38, win: 28 },
          { track: 'Concede + offer API / integration path', rep: 'Lily', uses: 8, toNext: 30, win: 19 },
        ],
      },
      {
        objection: 'We want to own the data',
        bestReply:
          'You do — Cedar reads and writes your existing CRM and Gmail; we’re not a separate system of record. Happy to send our infosec + data-handling doc and walk your security lead through it.',
        toNext: 58,
        winPct: 45,
        mentions: 5,
        talkTracks: [
          { track: 'Cedar writes to your CRM, not a new silo', rep: 'Willem', uses: 12, toNext: 58, win: 45 },
          { track: 'Share infosec + SOC2 Type II packet', rep: 'Sarah', uses: 9, toNext: 50, win: 38 },
          { track: 'Offer a data-handling review with security', rep: 'Lily', uses: 7, toNext: 44, win: 33 },
        ],
      },
      {
        objection: 'AI only helps with standardized, templated work',
        bestReply:
          'True for generic tools — Cedar runs YOUR playbook with full deal context, so it personalizes the non-standard parts. Can I draft a follow-up live on one of your real stalled deals?',
        toNext: 66,
        winPct: 60,
        mentions: 7,
        talkTracks: [
          { track: 'Draft live on a real stalled deal', rep: 'Willem', uses: 16, toNext: 66, win: 60 },
          { track: 'Show the 90% no-edit send rate', rep: 'Lily', uses: 10, toNext: 54, win: 47 },
          { track: 'Contrast vs generic ChatGPT output', rep: 'Sarah', uses: 9, toNext: 48, win: 40 },
        ],
      },
      {
        objection: 'The scaling problem is months away — not urgent now',
        bestReply:
          'Better to codify your best rep’s playbook before the new hires land — Cedar ramps new AEs to your CRM + follow-up standards without manager time. Want a 3-week pilot timed to the hire?',
        toNext: 49,
        winPct: 40,
        mentions: 5,
        talkTracks: [
          { track: 'Tie the pilot to the upcoming hire', rep: 'Willem', uses: 11, toNext: 49, win: 40 },
          { track: 'Ramp-time proof point (Cursor)', rep: 'Lily', uses: 8, toNext: 44, win: 35 },
        ],
      },
      {
        objection: 'This market is too competitive',
        bestReply:
          'Crowded on call recording — not on the writing + follow-up layer where deals actually slip. That’s the gap we own, and why Cursor, Warp, and Pylon picked us.',
        toNext: 64,
        winPct: 60,
        mentions: 6,
        talkTracks: [
          { track: 'Differentiate on the follow-up layer', rep: 'Willem', uses: 13, toNext: 64, win: 60 },
          { track: 'Name-drop Cursor / Warp / Pylon', rep: 'Sarah', uses: 9, toNext: 52, win: 44 },
        ],
      },
    ],
  },
  {
    n: 2,
    id: 'stage2',
    name: 'Demo',
    goal: "Live aha on the prospect's own pipeline + a named champion.",
    objections: [
      {
        objection: 'Our team works in the CRM, not Gmail',
        bestReply:
          'Cedar layers onto both — it reads/writes your CRM and drafts from Gmail, so reps don’t switch context. Want to see it update a HubSpot deal straight from an email thread?',
        toNext: 64,
        winPct: 60,
        mentions: 6,
        talkTracks: [
          { track: 'Show CRM write-back from an email', rep: 'Willem', uses: 12, toNext: 64, win: 60 },
          { track: "Layer onto, don't replace, the CRM", rep: 'Lily', uses: 9, toNext: 55, win: 48 },
        ],
      },
      {
        objection: 'We want to keep Gmail as our default',
        bestReply:
          'Keep it — Cedar runs inside your Gmail workflow, no migration. You only adopt Send when the drafts are good enough, and they hit a 90% no-edit rate.',
        toNext: 90,
        winPct: 88,
        mentions: 9,
        talkTracks: [
          { track: 'No migration — runs inside Gmail', rep: 'Willem', uses: 18, toNext: 90, win: 88 },
          { track: 'Adopt Send gradually as drafts prove out', rep: 'Lily', uses: 11, toNext: 80, win: 75 },
        ],
      },
      {
        objection: "Product's not ready — don't want to waste time",
        bestReply:
          'Fair — let’s not demo vapor. I’ll run it on your real pipeline, and if an integration isn’t ready we’ll scope the pilot around what is.',
        toNext: 48,
        winPct: 40,
        mentions: 6,
        talkTracks: [
          { track: 'Demo on real pipeline only', rep: 'Willem', uses: 10, toNext: 48, win: 40 },
          { track: 'Scope the pilot to ready integrations', rep: 'Sarah', uses: 8, toNext: 42, win: 34 },
        ],
      },
      {
        objection: "Differentiation doesn't show up in our sales-tool data",
        bestReply:
          'Exactly — Gong captures the call, not the writing + follow-up where your top rep wins. Cedar surfaces those tactics so you can clone them across the team.',
        toNext: 47,
        winPct: 40,
        mentions: 5,
        talkTracks: [
          { track: "Position vs Gong's blind spot", rep: 'Willem', uses: 9, toNext: 47, win: 40 },
          { track: 'Surface top-rep tactics to clone', rep: 'Lily', uses: 7, toNext: 43, win: 35 },
        ],
      },
      {
        objection: "Inbox layout doesn't match how we work",
        bestReply:
          'We mirror your Gmail sections (starred, then unread) — you keep your layout. Let me show the custom inbox so the switch is zero-friction.',
        toNext: 78,
        winPct: 75,
        mentions: 4,
        talkTracks: [
          { track: 'Mirror Gmail custom sections', rep: 'Willem', uses: 8, toNext: 78, win: 75 },
          { track: 'Zero-friction switch walkthrough', rep: 'Lily', uses: 6, toNext: 70, win: 66 },
        ],
      },
    ],
  },
  {
    n: 3,
    id: 'stage3',
    name: 'Negotiation',
    goal: 'Term sheet with a clear concession ladder and the pilot scoped.',
    objections: [
      {
        objection: 'Price is too high (founder doing sales)',
        bestReply:
          'You’re comparing to a $30/mo tool — Cedar replaces hours of admin and recovers slipped-deal revenue. One saved deal pays for the year. Want the ROI math on your ACV?',
        toNext: 49,
        winPct: 40,
        mentions: 5,
        talkTracks: [
          { track: 'Anchor to recovered-deal revenue, not tool price', rep: 'Willem', uses: 11, toNext: 49, win: 40 },
          { track: 'Run the ROI on their ACV', rep: 'Lily', uses: 8, toNext: 44, win: 35 },
          { track: 'Offer a founder / seat-based starter', rep: 'Sarah', uses: 6, toNext: 38, win: 28 },
        ],
      },
      {
        objection: 'Default cadence is too aggressive for enterprise',
        bestReply:
          'Fully configurable — we’ll set a long-cycle cadence so nothing feels spammy. Cedar enforces YOUR cadence, not a default.',
        toNext: 80,
        winPct: 75,
        mentions: 5,
        talkTracks: [
          { track: 'Configure a long-cycle cadence', rep: 'Willem', uses: 10, toNext: 80, win: 75 },
          { track: 'Show the cadence controls', rep: 'Lily', uses: 7, toNext: 72, win: 66 },
        ],
      },
      {
        objection: 'Champion lacks authority to approve a pilot',
        bestReply:
          'Let’s arm you to sell it up — I’ll send a one-page business case and offer a 15-min exec readout so you’re not carrying it alone.',
        toNext: 49,
        winPct: 40,
        mentions: 6,
        talkTracks: [
          { track: 'Arm the champion with a 1-pager', rep: 'Willem', uses: 11, toNext: 49, win: 40 },
          { track: 'Offer a 15-min exec readout', rep: 'Lily', uses: 9, toNext: 45, win: 36 },
        ],
      },
      {
        objection: 'Team has no bandwidth to evaluate',
        bestReply:
          'We run the pilot for you — setup is hours not weeks, and Cedar works in the background. Pick 5 deals and we’ll do the lift.',
        toNext: 30,
        winPct: 20,
        mentions: 6,
        talkTracks: [
          { track: 'We run the pilot (low lift)', rep: 'Willem', uses: 8, toNext: 30, win: 20 },
          { track: 'Background — no workflow change', rep: 'Lily', uses: 6, toNext: 26, win: 16 },
        ],
      },
      {
        objection: 'Not ready for a pilot — need internal approvals',
        bestReply:
          'Makes sense — let’s map the approval path now and pre-stage the security packet so the pilot starts the day you’re cleared.',
        toNext: 42,
        winPct: 33,
        mentions: 8,
        talkTracks: [
          { track: 'Map the approval path together', rep: 'Willem', uses: 12, toNext: 42, win: 33 },
          { track: 'Pre-stage the security packet', rep: 'Sarah', uses: 9, toNext: 38, win: 28 },
        ],
      },
    ],
  },
  {
    n: 4,
    id: 'stage4',
    name: 'Close',
    goal: 'Countersigned MSA + order form within 48h of verbal yes.',
    objections: [
      {
        objection: 'SOC 2 required before we can proceed',
        bestReply:
          'We’re SOC 2 Type II — here’s the report and DPA. Happy to set up a 30-min legal-to-legal to clear it fast.',
        toNext: 58,
        winPct: 50,
        mentions: 5,
        talkTracks: [
          { track: 'Send SOC2 Type II report + DPA', rep: 'Willem', uses: 11, toNext: 58, win: 50 },
          { track: 'Legal-to-legal in 30 min', rep: 'Lily', uses: 8, toNext: 52, win: 44 },
        ],
      },
      {
        objection: 'Integration needs IT-admin approval',
        bestReply:
          'Only the Gmail / CRM scopes need admin — I’ll send the exact permission list so IT can approve in one pass, and join the call if useful.',
        toNext: 72,
        winPct: 67,
        mentions: 3,
        talkTracks: [
          { track: 'Send the exact scope list to IT', rep: 'Willem', uses: 7, toNext: 72, win: 67 },
          { track: 'Offer to join the IT review call', rep: 'Lily', uses: 5, toNext: 64, win: 58 },
        ],
      },
      {
        objection: 'Salesforce sync lag breaks our source of truth',
        bestReply:
          'We can move you to near-real-time sync (or a webhook on deal update) so closed/updated deals reflect immediately — no more hourly lag.',
        toNext: 58,
        winPct: 50,
        mentions: 4,
        talkTracks: [
          { track: 'Enable near-real-time sync', rep: 'Willem', uses: 9, toNext: 58, win: 50 },
          { track: 'Webhook on deal update', rep: 'Sarah', uses: 6, toNext: 50, win: 42 },
        ],
      },
      {
        objection: 'An acquisition froze new-tool purchases',
        bestReply:
          'Understood — let’s keep the pilot warm and re-anchor once the new hierarchy settles. I’ll line up the intro to the new decision-maker.',
        toNext: 42,
        winPct: 33,
        mentions: 4,
        talkTracks: [
          { track: 'Keep the pilot warm, re-anchor post-M&A', rep: 'Willem', uses: 9, toNext: 42, win: 33 },
          { track: 'Get an intro to the new decision-maker', rep: 'Lily', uses: 7, toNext: 38, win: 28 },
        ],
      },
      {
        objection: 'Data-integrity bugs broke pipeline trust',
        bestReply:
          'We take that seriously — here’s the incident summary and the fix. Let’s re-verify your pipeline together so you can trust the view.',
        toNext: 58,
        winPct: 50,
        mentions: 4,
        talkTracks: [
          { track: 'Share the incident summary + fix', rep: 'Willem', uses: 8, toNext: 58, win: 50 },
          { track: 'Re-verify the pipeline live together', rep: 'Lily', uses: 6, toNext: 52, win: 44 },
        ],
      },
    ],
  },
];

const objectionPath = (stageId: string, objection: string) => `${OBJ_BASE}/${stageId}/objections/${slug(objection)}`;

// Competitive landscape (org-wide, shown in every stage's Positioning tab).
// Gong is an integration, not a competitor — hence the high win rate.
const competitors = [
  { competitor: 'Claude / ChatGPT', stance: 'Compete', encounter: 44, theyWin: 'Free-form drafting, cheap', weWin: 'Proactive, full deal context, playbook-controlled', winVs: 62 },
  { competitor: 'Status quo (Gmail + manual CRM)', stance: 'Compete', encounter: 51, theyWin: 'No new tool to adopt', weWin: 'Cut admin 75%, no deals slip', winVs: 60 },
  { competitor: 'Sybill', stance: 'Compete', encounter: 29, theyWin: 'Call notes + CRM autofill', weWin: 'Writing + follow-up + playbook execution', winVs: 55 },
  { competitor: 'Clari', stance: 'Compete', encounter: 24, theyWin: 'Forecast hygiene', weWin: 'Per-deal execution + next steps', winVs: 58 },
  { competitor: 'HubSpot Sequences', stance: 'Compete', encounter: 26, theyWin: 'Bundled with the CRM', weWin: 'Reply quality + deal context', winVs: 49 },
  { competitor: 'Gong', stance: 'Integrate', encounter: 38, theyWin: 'Call recording + coaching', weWin: 'We integrate — surface the writing/follow-up Gong misses', winVs: 78 },
];

// Pain points (org-wide, from "Customer Problems We Solve"). Shown in every stage's tab.
const painPoints = [
  { pain: 'Deals slip through the cracks on timing', feltBy: 'AE', freq: 64, win: 39 },
  { pain: 'Follow-ups are manual and impersonal', feltBy: 'AE', freq: 58, win: 36 },
  { pain: 'CRM busywork — moving info column A → B', feltBy: 'AE', freq: 55, win: 34 },
  { pain: 'Pipeline analysis = copy-pasting scattered context', feltBy: 'Strategic AE', freq: 47, win: 33 },
  { pain: "Can't clone the top rep's playbook", feltBy: 'Manager', freq: 38, win: 37 },
  { pain: 'New reps ramp slowly', feltBy: 'Manager', freq: 34, win: 31 },
  { pain: 'Dormant pipeline goes uncovered', feltBy: 'RevOps', freq: 29, win: 28 },
];

// Ideal customer profile — segment economics (shown in every stage's ICP tab).
const icpSegments = [
  { segment: 'Mid-market SaaS (50–500)', deals: 142, win: 41, avgDeal: 38_000, cycle: 34, signal: 'Champion + follow-up pain' },
  { segment: 'Enterprise (500+)', deals: 63, win: 33, avgDeal: 96_000, cycle: 61, signal: 'Multi-thread, security-ready' },
  { segment: 'Fintech', deals: 44, win: 37, avgDeal: 72_000, cycle: 52, signal: 'Compliance + SOC2 matters' },
  { segment: 'Healthcare', deals: 31, win: 29, avgDeal: 58_000, cycle: 58, signal: 'Slow legal, high ACV' },
  { segment: 'SMB (<50)', deals: 118, win: 22, avgDeal: 11_000, cycle: 18, signal: 'Fast cycle, price-sensitive' },
];

/** A standalone "← back" link to another Cedar Doc, resolved by path at render. */
function backLink(path: string, label: string): string {
  return fence({ dataSources: {}, layout: { type: 'docLink', path, label } });
}

// ── main-doc section builders ───────────────────────────────────────────────

// Per-rep quota attainment + closed-won (mock).
const attainment = [
  { rep: 'Willem', attainment: 142, closedWon: 1_620_000 },
  { rep: 'Sarah', attainment: 88, closedWon: 720_000 },
  { rep: 'Marcus', attainment: 61, closedWon: 310_000 },
  { rep: 'Lily', attainment: 79, closedWon: 540_000 },
];
const avgAttainment = mean(attainment.map((a) => a.attainment));
const totalPipeline = 4_250_000;

function orgConversionFence(): string {
  return fence({
    id: 'org_conversion',
    dataSources: {
      org: {
        mock: [
          {
            winRate: orgWinRate,
            deals: reps.length * 100,
            avgAttainment,
            pipeline: totalPipeline,
          },
        ],
      },
      funnel: { mock: orgFunnel },
    },
    layout: {
      type: 'column',
      gap: 'lg',
      children: [
        {
          type: 'grid',
          columns: 4,
          gap: 'lg',
          children: [
            { type: 'stat', source: 'org', field: 'winRate', label: 'Org win rate', format: 'percent', style: 'radial' },
            { type: 'stat', source: 'org', field: 'avgAttainment', label: 'Avg quota attainment', format: 'percent' },
            { type: 'stat', source: 'org', field: 'pipeline', label: 'Total pipeline', format: 'currency-compact' },
            { type: 'stat', source: 'org', field: 'deals', label: 'Deals (last 90 days)', format: 'number' },
          ],
        },
        { type: 'funnel', source: 'funnel', size: 'lg' },
      ],
    },
  });
}

function perRepFence(): string {
  const rows = reps.map((r) => ({
    name: r.name,
    initials: r.initials,
    label: r.label,
    steps: r.steps,
    attainment: attainment.find((a) => a.rep === r.name)?.attainment ?? 0,
    docPath: `${USERS_BASE}/${r.slug}`,
  }));
  return fence({
    id: 'per_rep_conversion',
    dataSources: { reps: { mock: rows } },
    layout: {
      type: 'repeater',
      source: 'reps',
      as: 'rep',
      layout: 'grid',
      columns: 4,
      gap: 'lg',
      template: {
        type: 'entityCard',
        linkField: 'docPath',
        children: [
          { type: 'profile', name: '{{rep.name}}', initials: '{{rep.initials}}', label: '{{rep.label}}' },
          { type: 'stat', field: 'attainment', label: 'Quota attainment', format: 'percent' },
          { type: 'funnel', field: 'steps' },
        ],
      },
    },
  });
}

function followupFence(): string {
  const outcome = [
    { outcome: 'Won deals', firstResponse: hours(1.2), followup: days(1.8), replyRate: 71, deals: 42 },
    { outcome: 'Lost deals', firstResponse: hours(5.4), followup: days(6.2), replyRate: 34, deals: 61 },
    { outcome: 'All deals', firstResponse: hours(3.8), followup: days(4.1), replyRate: 49, deals: 103 },
  ];
  // Follow-up content by type — includes Case study and the pattern-breaker class.
  const content = [
    { content: 'Recap + single CTA', replyRate: 71, win: 38 },
    { content: 'Mutual action plan', replyRate: 66, win: 35 },
    { content: 'Case study', replyRate: 59, win: 32 },
    { content: 'Pattern breaker', replyRate: 54, win: 29 },
    { content: 'Generic "just checking in"', replyRate: 18, win: 7 },
  ];
  // Pattern breakers — the actual emails that broke a no-reply streak.
  const breakers = [
    {
      name: 'The permission-to-close',
      subject: 'Should I close your file?',
      body: "Haven't heard back, which usually means one of three things: it's not a priority right now, the timing is off, or I dropped the ball. Totally fine either way — just tell me which and I'll act accordingly.",
      replyRate: 61,
      context: 'After 2 unanswered follow-ups',
    },
    {
      name: 'The trigger event',
      subject: 'Saw the Series B — congrats',
      body: 'Congrats on the raise. New headcount usually means more pipeline than the team can follow up on — which is exactly the gap we close. Worth 15 minutes now that you are scaling the team?',
      replyRate: 58,
      context: 'Triggered by funding news',
    },
    {
      name: 'The case-study drop',
      subject: 'How Ramp cut follow-up time 60%',
      body: 'You mentioned reps forgetting to follow up. Ramp had the same problem — attached is the one-pager on how they fixed it in 3 weeks. Happy to walk through it live if useful.',
      replyRate: 52,
      context: 'Mapped to a stated pain',
    },
  ];
  return fence({
    id: 'follow_ups',
    dataSources: {
      headline: { mock: [{ response: hours(3.8), postMeeting: hours(6), followup: days(4.1) }] },
      outcome: { mock: outcome },
      content: { mock: content },
      breakers: { mock: breakers },
    },
    layout: {
      type: 'column',
      gap: 'lg',
      children: [
        {
          type: 'grid',
          columns: 3,
          gap: 'lg',
          children: [
            { type: 'stat', source: 'headline', field: 'response', label: 'Avg response time', format: 'duration' },
            { type: 'stat', source: 'headline', field: 'postMeeting', label: 'Avg post-meeting follow-up', format: 'duration' },
            { type: 'stat', source: 'headline', field: 'followup', label: 'Avg follow-up time', format: 'duration' },
          ],
        },
        {
          type: 'table',
          source: 'outcome',
          title: 'Response & follow-up by outcome',
          columns: [
            { key=[redacted], label: 'Outcome', weight: 2 },
            { key=[redacted], label: 'Avg response time', format: 'duration', align: 'right' },
            { key=[redacted], label: 'Avg follow-up gap', format: 'duration', align: 'right' },
            { key=[redacted], label: 'Reply rate', format: 'percent', align: 'right' },
            { key=[redacted], label: 'Deals', format: 'number', align: 'right' },
          ],
        },
        {
          type: 'table',
          source: 'content',
          title: 'Follow-up content — what gets replies',
          columns: [
            { key=[redacted], label: 'Content type', weight: 3 },
            { key=[redacted], label: 'Reply rate', format: 'percent', align: 'right' },
            { key: 'win', label: 'Win %', format: 'percent', align: 'right' },
          ],
        },
        {
          type: 'text',
          markdown:
            '### Pattern breakers\nThe emails that break a no-reply streak — short, specific, and easy to say yes (or no) to.',
        },
        {
          type: 'repeater',
          source: 'breakers',
          as: 'breaker',
          layout: 'list',
          gap: 'md',
          template: {
            type: 'entityCard',
            children: [
              { type: 'text', markdown: '**{{breaker.name}}** · {{breaker.context}} · replies {{breaker.replyRate}}%' },
              { type: 'text', markdown: '**Subject:** {{breaker.subject}}' },
              { type: 'text', markdown: '{{breaker.body}}', className: 'text-muted-foreground italic' },
            ],
          },
        },
      ],
    },
  });
}

function stageFence(stage: Stage): string {
  const objectionRows = stage.objections.map((o) => ({
    objection: o.objection,
    analysisPath: objectionPath(stage.id, o.objection),
    mentions: o.mentions,
    bestReply: o.bestReply,
    win: o.winPct,
    toNext: o.toNext,
  }));
  // Talk tracks tab — every track used against this stage's objections.
  const trackRows = stage.objections.flatMap((o) =>
    o.talkTracks.map((t) => ({
      track: t.track,
      objection: o.objection,
      rep: t.rep,
      uses: t.uses,
      toNext: t.toNext,
      win: t.win,
    })),
  );
  // Each tab pairs a detailed table with a win-rate chart.
  const tab = (label: string, table: Record<string, unknown>, chart: Record<string, unknown>) => ({
    label,
    node: { type: 'column', gap: 'lg', children: [table, chart] },
  });
  return fence({
    id: `${stage.id}_playbook`,
    dataSources: {
      objections: { mock: objectionRows },
      positioning: { mock: competitors },
      tracks: { mock: trackRows },
      pains: { mock: painPoints },
      icp: { mock: icpSegments },
    },
    layout: {
      type: 'tabs',
      tabs: [
        tab(
          'Objections',
          {
            type: 'table',
            source: 'objections',
            columns: [
              { key=[redacted], label: 'Objection', weight: 3 },
              { key=[redacted], label: 'Analysis', type: 'docLink', linkLabel: 'Analysis', weight: 1 },
              { key=[redacted], label: 'Deals seen', format: 'number', align: 'right' },
              { key=[redacted], label: 'Best-performing response', weight: 4 },
              { key: 'win', label: 'Win %', format: 'percent', align: 'right' },
              { key=[redacted], label: 'Move to next', format: 'percent', align: 'right' },
            ],
          },
          { type: 'chart', source: 'objections', chart: 'bar', orientation: 'horizontal', title: 'Win rate by objection', xAxis: 'objection', series: [{ key: 'win', label: 'Win %' }] },
        ),
        tab(
          'Competitive Positioning',
          {
            type: 'table',
            source: 'positioning',
            columns: [
              { key=[redacted], label: 'Competitor', weight: 2 },
              { key=[redacted], label: 'Stance', weight: 1 },
              { key=[redacted], label: 'Encounter %', format: 'percent', align: 'right' },
              { key=[redacted], label: 'They win on', weight: 2 },
              { key=[redacted], label: 'We win on', weight: 3 },
              { key=[redacted], label: 'Win rate vs', format: 'percent', align: 'right' },
            ],
          },
          { type: 'chart', source: 'positioning', chart: 'bar', orientation: 'horizontal', title: 'Win rate vs competitor', xAxis: 'competitor', series: [{ key=[redacted], label: 'Win rate vs' }] },
        ),
        tab(
          'Talk tracks',
          {
            type: 'table',
            source: 'tracks',
            columns: [
              { key=[redacted], label: 'Talk track', weight: 3 },
              { key=[redacted], label: 'Used against', weight: 2 },
              { key: 'rep', label: 'Top rep', type: 'rep', weight: 1 },
              { key=[redacted], label: 'Uses', format: 'number', align: 'right' },
              { key=[redacted], label: 'Move to next', format: 'percent', align: 'right' },
              { key: 'win', label: 'Win %', format: 'percent', align: 'right' },
            ],
          },
          { type: 'chart', source: 'tracks', chart: 'bar', orientation: 'horizontal', title: 'Win rate by talk track', xAxis: 'track', series: [{ key: 'win', label: 'Win %' }] },
        ),
        tab(
          'Pain points',
          {
            type: 'table',
            source: 'pains',
            columns: [
              { key=[redacted], label: 'Pain point', weight: 3 },
              { key=[redacted], label: 'Felt by', weight: 1 },
              { key=[redacted], label: 'Frequency', format: 'percent', align: 'right' },
              { key: 'win', label: 'Win % when raised', format: 'percent', align: 'right' },
            ],
          },
          { type: 'chart', source: 'pains', chart: 'bar', orientation: 'horizontal', title: 'Win rate by pain point', xAxis: 'pain', series: [{ key: 'win', label: 'Win %' }] },
        ),
        tab(
          'ICP',
          {
            type: 'table',
            source: 'icp',
            columns: [
              { key=[redacted], label: 'Segment', weight: 3 },
              { key=[redacted], label: 'Deals', format: 'number', align: 'right' },
              { key: 'win', label: 'Win %', format: 'percent', align: 'right' },
              { key=[redacted], label: 'Avg deal', format: 'currency-compact', align: 'right' },
              { key=[redacted], label: 'Cycle (days)', format: 'number', align: 'right' },
              { key=[redacted], label: 'Best-fit signal', weight: 2 },
            ],
          },
          { type: 'chart', source: 'icp', chart: 'bar', orientation: 'horizontal', title: 'Win rate by segment', xAxis: 'segment', series: [{ key: 'win', label: 'Win %' }] },
        ),
      ],
    },
  });
}

// ── subdocument builders ──────────────────────────────────────────────────────

function repSubdoc(rep: Rep): PlaybookDocument {
  const call = callStats[rep.slug];
  const winFor = (title: string) => rep.stageWin.find((s) => s.stage === title)?.win;
  const playbookRows = rep.playbook.map((card) => ({ ...card, winPct: winFor(card.title) }));

  const markdown = [
    `# ${rep.name} — playbook`,
    ``,
    backLink(MAIN_PATH, '← Back to team playbook'),
    ``,
    `**${rep.label}.** How ${rep.name} converts pipeline versus the team, and the response habits, call behaviour, and stage-by-stage moves behind it.`,
    ``,
    // 1) Conversion vs team — a single comparison (no duplicate funnel).
    `## Conversion vs the team`,
    ``,
    fence({
      id: `rep_${rep.slug}_compare`,
      dataSources: {
        rep: { mock: [{ demo: rep.demo, negotiation: rep.negotiation, close: rep.close, winRate: rep.winRate }] },
        team: { mock: [teamRow] },
      },
      layout: {
        type: 'comparison',
        source: 'rep',
        teamSource: 'team',
        rowLabel: rep.name,
        teamLabel: 'Team avg',
        format: 'percent',
        fields: [
          { key=[redacted], label: 'Reaches demo' },
          { key=[redacted], label: 'Reaches negotiation' },
          { key=[redacted], label: 'Reaches close' },
          { key=[redacted], label: 'Win rate' },
        ],
      },
    }),
    ``,
    // 2) Follow-up & responses.
    `## Follow-up & responses`,
    ``,
    fence({
      id: `rep_${rep.slug}_followup`,
      dataSources: {
        headline: {
          mock: [{ firstResponse: rep.firstResponseSec, followup: rep.followupSec, replyRate: rep.replyRate }],
        },
        vsTeam: {
          mock: [
            { metric: 'Median first response', you: dur(rep.firstResponseSec), team: dur(teamFollow.firstResponseSec) },
            { metric: 'Median follow-up gap', you: dur(rep.followupSec), team: dur(teamFollow.followupSec) },
            { metric: 'Reply rate', you: `${rep.replyRate}%`, team: `${teamFollow.replyRate}%` },
          ],
        },
        content: { mock: followupContent[rep.slug] },
      },
      layout: {
        type: 'column',
        gap: 'lg',
        children: [
          {
            type: 'grid',
            columns: 3,
            gap: 'lg',
            children: [
              { type: 'stat', source: 'headline', field: 'firstResponse', label: 'Median first response', format: 'duration' },
              { type: 'stat', source: 'headline', field: 'followup', label: 'Median follow-up gap', format: 'duration' },
              { type: 'stat', source: 'headline', field: 'replyRate', label: 'Reply rate', format: 'percent' },
            ],
          },
          {
            type: 'table',
            source: 'vsTeam',
            title: `${rep.name} vs team`,
            columns: [
              { key=[redacted], label: 'Metric', weight: 2 },
              { key: 'you', label: rep.name, align: 'right' },
              { key=[redacted], label: 'Team avg', align: 'right' },
            ],
          },
          {
            type: 'table',
            source: 'content',
            title: 'Follow-up content that works',
            columns: [
              { key=[redacted], label: 'Follow-up content', weight: 3 },
              { key=[redacted], label: 'Reply rate', format: 'percent', align: 'right' },
              { key: 'win', label: 'Win %', format: 'percent', align: 'right' },
            ],
          },
        ],
      },
    }),
    ``,
    // 3) Call intelligence.
    `## Call intelligence`,
    ``,
    fence({
      id: `rep_${rep.slug}_calls`,
      dataSources: {
        headline: { mock: [{ perWeek: call.perWeek, talkRatio: call.talkRatio, avgCallMin: call.avgCallMin }] },
        vsTeam: {
          mock: [
            { metric: 'Talk : listen ratio', you: `${call.talkRatio}% / ${100 - call.talkRatio}%`, team: `${teamCall.talkRatio}% / ${100 - teamCall.talkRatio}%` },
            { metric: 'Avg call length', you: `${call.avgCallMin}m`, team: `${teamCall.avgCallMin}m` },
            { metric: 'Discovery questions / call', you: `${call.discoveryQs}`, team: `${teamCall.discoveryQs}` },
            { metric: 'Longest monologue', you: dur(call.monologueSec), team: dur(teamCall.monologueSec) },
            { metric: 'Calls ending with a next step', you: `${call.nextStepRate}%`, team: `${teamCall.nextStepRate}%` },
          ],
        },
      },
      layout: {
        type: 'column',
        gap: 'lg',
        children: [
          {
            type: 'grid',
            columns: 3,
            gap: 'lg',
            children: [
              { type: 'stat', source: 'headline', field: 'perWeek', label: 'Calls / week', format: 'number' },
              { type: 'stat', source: 'headline', field: 'talkRatio', label: 'Talk ratio', format: 'percent' },
              { type: 'stat', source: 'headline', field: 'avgCallMin', label: 'Avg call (min)', format: 'number' },
            ],
          },
          {
            type: 'table',
            source: 'vsTeam',
            title: `${rep.name} vs team`,
            columns: [
              { key=[redacted], label: 'Signal', weight: 2 },
              { key: 'you', label: rep.name, align: 'right' },
              { key=[redacted], label: 'Team avg', align: 'right' },
            ],
          },
        ],
      },
    }),
    ``,
    // 4) Stage-by-stage playbook (win rate folded into each card).
    `## Playbook by stage`,
    ``,
    fence({
      id: `rep_${rep.slug}_playbook`,
      dataSources: { playbook: { mock: playbookRows } },
      layout: { type: 'playbook', source: 'playbook' },
    }),
    ``,
  ].join('\n');
  return { path: `${USERS_BASE}/${rep.slug}`, title: rep.name, markdown };
}

function objectionSubdoc(stage: Stage, o: Objection): PlaybookDocument {
  const path = objectionPath(stage.id, o.objection);
  const markdown = [
    `# ${o.objection}`,
    ``,
    backLink(MAIN_PATH, '← Back to team playbook'),
    ``,
    `Stage ${stage.n} — ${stage.name}. **Best-performing response:** ${o.bestReply}`,
    ``,
    `Seen in **${o.mentions} deals**. Overall it moves to the next step **${o.toNext}%** of the time and wins **${o.winPct}%**. Talk-track performance below.`,
    ``,
    fence({
      id: `${stage.id}_obj_${slug(o.objection)}`,
      dataSources: { tracks: { mock: o.talkTracks } },
      layout: {
        type: 'column',
        gap: 'lg',
        children: [
          {
            type: 'table',
            source: 'tracks',
            title: 'Talk tracks for this objection',
            columns: [
              { key=[redacted], label: 'Talk track', weight: 3 },
              { key: 'rep', label: 'Top user', type: 'rep', weight: 2 },
              { key=[redacted], label: 'Uses', format: 'number', align: 'right' },
              { key=[redacted], label: 'Move to next step', format: 'percent', align: 'right' },
              { key: 'win', label: 'Win %', format: 'percent', align: 'right' },
            ],
          },
          {
            type: 'chart',
            source: 'tracks',
            chart: 'bar',
            orientation: 'horizontal',
            title: 'Win rate by talk track',
            xAxis: 'track',
            series: [{ key: 'win', label: 'Win %' }],
          },
        ],
      },
    }),
    ``,
  ].join('\n');
  return { path, title: o.objection, markdown };
}

// ── assembly ────────────────────────────────────────────────────────────────

function mainDoc(): PlaybookDocument {
  const parts: string[] = [
    `# What is your top rep doing differently?`,
    ``,
    `A living breakdown of how the team converts pipeline — org and per-rep conversion, response habits, and the per-stage patterns, objections, messaging, features, and pain points that move deals. Last 90 days, 100 deals per rep.`,
    ``,
    `## Org-wide conversion rate`,
    ``,
    orgConversionFence(),
    ``,
    `## Per rep conversion rate`,
    ``,
    `Each card is a rep's funnel. Click a rep to open their full playbook — their conversion versus the team average and what they do at each stage.`,
    ``,
    perRepFence(),
    ``,
    `## Playbook`,
    ``,
    `### Follow-up`,
    ``,
    `Speed and cadence of follow-up is the biggest separator between won and lost deals. Times are medians; "first response" is the gap from an inbound message to the first reply, "follow-up gap" is the gap between consecutive outbound emails.`,
    ``,
    followupFence(),
    ``,
  ];
  for (const stage of stages) {
    parts.push(
      `### Stage ${stage.n} — ${stage.name}`,
      ``,
      `**Goal:** ${stage.goal}`,
      ``,
      `Each objection links to a breakdown of the talk tracks reps use against it and how each performs.`,
      ``,
      stageFence(stage),
      ``,
    );
  }
  return { path: MAIN_PATH, title: 'What is your top rep doing differently?', markdown: parts.join('\n') };
}

export const playbookDocuments: PlaybookDocument[] = [
  mainDoc(),
  ...reps.map(repSubdoc),
  ...stages.flatMap((stage) => stage.objections.map((o) => objectionSubdoc(stage, o))),
];

/** The main document's markdown (used by the standalone demo route). */
export const topRepPlaybookMarkdown = playbookDocuments[0].markdown;