create-battle-plan 1.3.0 → 1.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,55 @@
1
+ #!/usr/bin/env node
2
+ // tools/events/archive.js — move gated/completed/cancelled events from events.yml → events-archive.yml.
3
+ // Mirrors tools/tasks/archive.js.
4
+ //
5
+ // An event is archivable when:
6
+ // - status ∈ {done, cancelled, no_show, rescheduled} AND gate_completed_at is set (normal path), OR
7
+ // - end-time > 14 days ago (auto-no-show sweep for stale scheduled events)
8
+ //
9
+ // Flags: --dry-run, --days N (override stale threshold)
10
+
11
+ const E = require('./lib/events');
12
+
13
+ const args = process.argv.slice(2);
14
+ const dry = args.includes('--dry-run');
15
+ const daysIdx = args.indexOf('--days');
16
+ const staleDays = daysIdx >= 0 ? parseInt(args[daysIdx + 1], 10) : 14;
17
+
18
+ const cutoff = new Date(Date.now() - staleDays * 86400 * 1000).toISOString();
19
+ const live = E.load();
20
+ const archive = E.loadArchive();
21
+
22
+ const archivable = [];
23
+ const remaining = [];
24
+ for (const e of live.events) {
25
+ const terminal = ['done', 'cancelled', 'no_show', 'rescheduled'].includes(e.status);
26
+ const gated = !!e.gate_completed_at;
27
+ const ref = e.end || e.start;
28
+ const stale = ref && ref < cutoff;
29
+ if (terminal && (gated || stale)) {
30
+ archivable.push(e);
31
+ } else if (!terminal && stale) {
32
+ e.status = 'no_show';
33
+ archivable.push(e);
34
+ } else {
35
+ remaining.push(e);
36
+ }
37
+ }
38
+
39
+ console.log(`Archivable: ${archivable.length} · Remaining in events.yml: ${remaining.length}`);
40
+ for (const e of archivable) {
41
+ console.log(` EVT-${e.id} ${e.start.slice(0, 16).replace('T', ' ')} [${e.status}] ${e.title}`);
42
+ }
43
+
44
+ if (dry) { console.log('\nDry-run.'); process.exit(0); }
45
+ if (!archivable.length) process.exit(0);
46
+
47
+ const archiveIds = new Set(archive.events.map(e => e.id));
48
+ for (const e of archivable) {
49
+ if (!archiveIds.has(e.id)) archive.events.push(e);
50
+ }
51
+ live.events = remaining;
52
+
53
+ E.save(live);
54
+ E.saveArchive(archive);
55
+ console.log(`\nMoved ${archivable.length} event(s) to events-archive.yml.`);
@@ -0,0 +1,31 @@
1
+ #!/usr/bin/env node
2
+ // tools/events/due-for-gate.js — events whose end-time has passed but no gate_completed_at yet.
3
+ // The wrap-up skill walks these one-by-one and asks for: transcript path, hypothesis impacts,
4
+ // spawned tasks, lead status change. Then stamps gate_completed_at and archives.
5
+ // Flags: --json
6
+ // Used by wrap-up (mandatory gate) and good-morning (warns when context-debt > 2 days old).
7
+
8
+ const E = require('./lib/events');
9
+
10
+ const asJson = process.argv.includes('--json');
11
+ const events = E.dueForGate();
12
+
13
+ if (asJson) {
14
+ console.log(JSON.stringify(events, null, 2));
15
+ process.exit(0);
16
+ }
17
+
18
+ if (!events.length) {
19
+ console.log('(no events due for wrap-up gate)');
20
+ process.exit(0);
21
+ }
22
+
23
+ console.log(`${events.length} event(s) past end-time, awaiting wrap-up gate:`);
24
+ for (const e of events) {
25
+ const start = e.start.slice(0, 16).replace('T', ' ');
26
+ console.log(` EVT-${e.id} ${start} [${e.type}] ${e.title}`);
27
+ if (e.lead_id) console.log(` lead: ${e.lead_id}`);
28
+ if (!e.transcript_path) console.log(' · transcript: MISSING');
29
+ if (!e.hypothesis_impacts || !e.hypothesis_impacts.length) console.log(' · hypothesis impacts: MISSING');
30
+ if (!e.spawned_tasks || !e.spawned_tasks.length) console.log(' · spawned tasks: none recorded');
31
+ }
@@ -0,0 +1,221 @@
1
+ // tools/events/lib/events.js — YAML reader/writer for events.yml + events-archive.yml.
2
+ // Mirrors the shape of tools/tasks/lib/tasks.js — self-contained, no external YAML deps.
3
+ // An "event" is something with a start datetime AND a counterparty (calls, demos, meetings,
4
+ // dentist appointments). Deadline-only items (no time-of-day, no attendee) belong in tasks.yml.
5
+
6
+ const fs = require('fs');
7
+ const path = require('path');
8
+
9
+ const ROOT = path.resolve(__dirname, '../../..');
10
+ const EVENTS_PATH = path.join(ROOT, 'events.yml');
11
+ const ARCHIVE_PATH = path.join(ROOT, 'events-archive.yml');
12
+
13
+ const VALID_STATUS = new Set(['scheduled', 'in_progress', 'done', 'cancelled', 'no_show', 'rescheduled']);
14
+ // VALID_TYPE is intentionally permissive — adapt per project. The set below lists common types;
15
+ // any string passes through (add.js validates against this set only for known categories).
16
+ const VALID_TYPE = new Set(['demo', 'discovery', 'investor', 'admin', 'personal', 'unspecified', 'other']);
17
+
18
+ const FIELD_ORDER = [
19
+ 'id', 'title', 'start', 'end', 'type', 'status',
20
+ 'attendees', 'lead_id', 'location', 'notes',
21
+ 'source', 'created_at',
22
+ 'transcript_path', 'spawned_tasks', 'hypothesis_impacts', 'gate_completed_at'
23
+ ];
24
+
25
+ function today() { return new Date().toISOString().slice(0, 10); }
26
+ function nowIso() { return new Date().toISOString(); }
27
+
28
+ function parseScalar(raw) {
29
+ const s = raw.trim();
30
+ if (s === '' || s === 'null' || s === '~') return null;
31
+ if (s === 'true') return true;
32
+ if (s === 'false') return false;
33
+ if (/^-?\d+$/.test(s)) return parseInt(s, 10);
34
+ if ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'"))) {
35
+ return s.slice(1, -1).replace(/\\"/g, '"').replace(/\\\\/g, '\\');
36
+ }
37
+ if (s.startsWith('[') && s.endsWith(']')) {
38
+ const inner = s.slice(1, -1).trim();
39
+ if (!inner) return [];
40
+ return inner.split(',').map(x => parseScalar(x.trim()));
41
+ }
42
+ return s;
43
+ }
44
+
45
+ function serializeString(s) {
46
+ s = String(s);
47
+ if (s === '' || /^(null|true|false|~)$/.test(s) || /^-?\d+$/.test(s)) {
48
+ return '"' + s.replace(/\\/g, '\\\\').replace(/"/g, '\\"') + '"';
49
+ }
50
+ if (/[:#\[\]{},&*!|>'"%@`\n]|^[\s-?]/.test(s)) {
51
+ return '"' + s.replace(/\\/g, '\\\\').replace(/"/g, '\\"') + '"';
52
+ }
53
+ return s;
54
+ }
55
+
56
+ function serializeScalar(v) {
57
+ if (v === null || v === undefined) return 'null';
58
+ if (typeof v === 'number' || typeof v === 'boolean') return String(v);
59
+ if (Array.isArray(v)) {
60
+ if (v.length === 0) return '[]';
61
+ return '[' + v.map(x => (typeof x === 'number' || typeof x === 'boolean') ? String(x) : serializeString(x)).join(', ') + ']';
62
+ }
63
+ return serializeString(v);
64
+ }
65
+
66
+ function loadFile(p) {
67
+ if (!fs.existsSync(p)) {
68
+ return { last_updated: today(), next_id: 1, events: [] };
69
+ }
70
+ const text = fs.readFileSync(p, 'utf8');
71
+ const lines = text.split('\n');
72
+ const result = { last_updated: today(), next_id: 1, events: [] };
73
+ let i = 0;
74
+ while (i < lines.length) {
75
+ const line = lines[i];
76
+ if (/^\s*#/.test(line) || line.trim() === '') { i++; continue; }
77
+ if (/^events\s*:\s*$/.test(line)) { i++; break; }
78
+ const m = line.match(/^(\w+)\s*:\s*(.*)$/);
79
+ if (m) result[m[1]] = parseScalar(m[2]);
80
+ i++;
81
+ }
82
+ let cur = null;
83
+ for (; i < lines.length; i++) {
84
+ const line = lines[i];
85
+ if (/^\s*#/.test(line) || line.trim() === '') continue;
86
+ const listItem = line.match(/^\s*-\s+(\w+)\s*:\s*(.*)$/);
87
+ if (listItem) {
88
+ if (cur) result.events.push(cur);
89
+ cur = {};
90
+ cur[listItem[1]] = parseScalar(listItem[2]);
91
+ continue;
92
+ }
93
+ const field = line.match(/^\s+(\w+)\s*:\s*(.*)$/);
94
+ if (field && cur) cur[field[1]] = parseScalar(field[2]);
95
+ }
96
+ if (cur) result.events.push(cur);
97
+ result.next_id = typeof result.next_id === 'number' ? result.next_id : parseInt(result.next_id, 10) || 1;
98
+ result.events.forEach(e => {
99
+ if (typeof e.id === 'string') e.id = parseInt(e.id, 10);
100
+ for (const k of ['attendees', 'spawned_tasks', 'hypothesis_impacts']) {
101
+ if (e[k] && !Array.isArray(e[k])) e[k] = [e[k]];
102
+ }
103
+ });
104
+ return result;
105
+ }
106
+
107
+ function saveFile(p, state, header) {
108
+ state.last_updated = today();
109
+ const maxId = state.events.reduce((m, e) => Math.max(m, e.id || 0), 0);
110
+ if (state.next_id <= maxId) state.next_id = maxId + 1;
111
+ const out = [];
112
+ out.push(header);
113
+ out.push(`last_updated: ${state.last_updated}`);
114
+ out.push(`next_id: ${state.next_id}`);
115
+ out.push('events:');
116
+ for (const e of state.events) {
117
+ let first = true;
118
+ for (const k of FIELD_ORDER) {
119
+ if (!(k in e)) continue;
120
+ const prefix = first ? ' - ' : ' ';
121
+ out.push(`${prefix}${k}: ${serializeScalar(e[k])}`);
122
+ first = false;
123
+ }
124
+ }
125
+ fs.writeFileSync(p, out.join('\n') + '\n');
126
+ }
127
+
128
+ function load() { return loadFile(EVENTS_PATH); }
129
+ function loadArchive() { return loadFile(ARCHIVE_PATH); }
130
+
131
+ function save(state) {
132
+ saveFile(EVENTS_PATH, state,
133
+ '# events.yml — single source of truth for future time-based events (calls, demos, meetings).\n# Past/completed events live in events-archive.yml. Tasks (deadline-only, no time-span) live in tasks.yml.');
134
+ }
135
+
136
+ function saveArchive(state) {
137
+ saveFile(ARCHIVE_PATH, state,
138
+ '# events-archive.yml — past/completed events. Mirrors events.yml schema.\n# Wrap-up gate moves events here after transcript + insights captured.');
139
+ }
140
+
141
+ function nextId(state) {
142
+ // Shared ID space across events.yml + events-archive.yml. An event's ID never changes when archived.
143
+ const cur = state.next_id || 1;
144
+ const archived = loadArchive();
145
+ const maxArch = archived.events.reduce((m, e) => Math.max(m, e.id || 0), 0);
146
+ const id = Math.max(cur, maxArch + 1);
147
+ state.next_id = id + 1;
148
+ return id;
149
+ }
150
+
151
+ // Idempotent: matches on (id) first, then (lead_id + start) tuple. Safe to call from
152
+ // retried flush-* scripts without creating duplicate rows for the same real-world event.
153
+ function upsert(state, event) {
154
+ let idx = -1;
155
+ if (event.id) idx = state.events.findIndex(e => e.id === event.id);
156
+ if (idx < 0 && event.lead_id && event.start) {
157
+ idx = state.events.findIndex(e => e.lead_id === event.lead_id && e.start === event.start);
158
+ }
159
+ if (idx >= 0) {
160
+ state.events[idx] = { ...state.events[idx], ...event };
161
+ return { action: 'updated', event: state.events[idx] };
162
+ }
163
+ if (!event.id) event.id = nextId(state);
164
+ if (!event.created_at) event.created_at = nowIso();
165
+ state.events.push(event);
166
+ return { action: 'inserted', event };
167
+ }
168
+
169
+ function upcoming({ from = nowIso() } = {}) {
170
+ return load().events
171
+ .filter(e => e.start && e.start >= from && (e.status === 'scheduled' || e.status === 'in_progress' || e.status === 'done'))
172
+ .sort((a, b) => String(a.start).localeCompare(String(b.start)));
173
+ }
174
+
175
+ function todayEvents({ on = today() } = {}) {
176
+ return load().events
177
+ .filter(e => e.start && String(e.start).startsWith(on)
178
+ && (e.status === 'scheduled' || e.status === 'in_progress' || e.status === 'done'))
179
+ .sort((a, b) => String(a.start).localeCompare(String(b.start)));
180
+ }
181
+
182
+ function dueForGate({ now = nowIso() } = {}) {
183
+ return load().events
184
+ .filter(e => {
185
+ if (e.gate_completed_at) return false;
186
+ if (e.status === 'cancelled' || e.status === 'rescheduled') return false;
187
+ const endOrStart = e.end || e.start;
188
+ return endOrStart && endOrStart < now;
189
+ })
190
+ .sort((a, b) => String(a.start).localeCompare(String(b.start)));
191
+ }
192
+
193
+ // Lead-level lookup — used by metrics scripts to know if a lead ever had a call.
194
+ // Unions events.yml + events-archive.yml so historical calls survive after archival.
195
+ function leadHadCall(leadId) {
196
+ if (!leadId) return false;
197
+ const all = [...load().events, ...loadArchive().events];
198
+ return all.some(e => e.lead_id === leadId
199
+ && (e.status === 'done' || e.status === 'in_progress'
200
+ || (e.status === 'scheduled' && e.start && e.start < nowIso())));
201
+ }
202
+
203
+ function eventsByLead(leadId) {
204
+ if (!leadId) return [];
205
+ return [...load().events, ...loadArchive().events].filter(e => e.lead_id === leadId);
206
+ }
207
+
208
+ function defaultEnd(startIso, durationMin = 30) {
209
+ if (!startIso) return null;
210
+ const d = new Date(startIso);
211
+ if (isNaN(d.getTime())) return null;
212
+ d.setMinutes(d.getMinutes() + durationMin);
213
+ return d.toISOString();
214
+ }
215
+
216
+ module.exports = {
217
+ EVENTS_PATH, ARCHIVE_PATH, VALID_STATUS, VALID_TYPE, FIELD_ORDER,
218
+ today, nowIso, defaultEnd,
219
+ load, loadArchive, save, saveArchive, nextId, upsert,
220
+ upcoming, todayEvents, dueForGate, leadHadCall, eventsByLead
221
+ };
@@ -0,0 +1,90 @@
1
+ #!/usr/bin/env node
2
+ // tools/events/migrate-from-csv.js — one-shot migration for installs that previously stored
3
+ // scheduled calls as outreach/leads.csv:call_at. Splits the column into events.yml (future)
4
+ // + events-archive.yml (past), then strips the call_at values from leads.csv.
5
+ //
6
+ // Idempotent — keys on (lead_id + start). Safe to re-run; already-present rows are skipped.
7
+ // Profile A (base only) installs without leads.csv: the script no-ops with a helpful message.
8
+ //
9
+ // Run with --commit to persist; otherwise dry-run.
10
+
11
+ const fs = require('fs');
12
+ const path = require('path');
13
+ const E = require('./lib/events');
14
+
15
+ const commit = process.argv.includes('--commit');
16
+ const todayStr = E.today();
17
+
18
+ const LEADS_LIB = path.resolve(__dirname, '../outreach/lib/leads.js');
19
+ if (!fs.existsSync(LEADS_LIB)) {
20
+ console.log('No outreach/lib/leads.js found — this install has no CSV to migrate from.');
21
+ console.log('events.yml + events-archive.yml will be created on first add.');
22
+ process.exit(0);
23
+ }
24
+
25
+ const L = require('../outreach/lib/leads');
26
+ const rows = L.load();
27
+ const withCallAt = rows.filter(r => r.call_at && String(r.call_at).trim() !== '');
28
+
29
+ console.log(`Found ${withCallAt.length} leads with call_at set.`);
30
+ if (withCallAt.length === 0) {
31
+ console.log('Nothing to migrate.');
32
+ process.exit(0);
33
+ }
34
+
35
+ const future = E.load();
36
+ const archive = E.loadArchive();
37
+
38
+ let nFuture = 0, nArchive = 0, nSkipped = 0;
39
+
40
+ for (const r of withCallAt) {
41
+ const raw = String(r.call_at).trim();
42
+ // Bare YYYY-MM-DD → assume UTC midnight so date doesn't shift. Adapt offset if relevant.
43
+ const start = raw.length === 10 ? `${raw}T00:00:00+00:00` : raw;
44
+ const dateOnly = raw.slice(0, 10);
45
+ const isFuture = dateOnly >= todayStr;
46
+ const target = isFuture ? future : archive;
47
+ const status = isFuture ? 'scheduled' : 'done';
48
+ const name = [r.first_name, r.last_name].filter(Boolean).join(' ') || r.company || r.linkedin_url;
49
+ const title = `${status === 'done' ? 'Call' : 'Call (scheduled)'} — ${name}${r.company ? ` / ${r.company}` : ''}`;
50
+
51
+ const event = {
52
+ title,
53
+ start,
54
+ end: isFuture ? E.defaultEnd(start, 30) : null,
55
+ type: 'unspecified',
56
+ status,
57
+ attendees: [],
58
+ lead_id: r.linkedin_url || null,
59
+ location: null,
60
+ notes: null,
61
+ source: 'migrate-from-csv',
62
+ };
63
+
64
+ const existingIdx = target.events.findIndex(e => e.lead_id === event.lead_id && e.start === event.start);
65
+ if (existingIdx >= 0) { nSkipped++; continue; }
66
+
67
+ E.upsert(target, event);
68
+ if (isFuture) nFuture++; else nArchive++;
69
+ }
70
+
71
+ console.log(`Future events to insert: ${nFuture}`);
72
+ console.log(`Archive events to insert: ${nArchive}`);
73
+ console.log(`Already-present skipped: ${nSkipped}`);
74
+
75
+ if (!commit) {
76
+ console.log('\nDry-run. Re-run with --commit to write events.yml + events-archive.yml + strip call_at.');
77
+ process.exit(0);
78
+ }
79
+
80
+ E.save(future);
81
+ E.saveArchive(archive);
82
+ console.log('\nWrote events.yml + events-archive.yml.');
83
+
84
+ console.log('\nStripping call_at values from leads.csv...');
85
+ let stripped = 0;
86
+ for (const r of rows) {
87
+ if (r.call_at && r.call_at !== '') { r.call_at = ''; stripped++; }
88
+ }
89
+ L.save(rows);
90
+ console.log(`Cleared call_at on ${stripped} rows. The column itself is dropped on next save once HEADERS is updated.`);
@@ -0,0 +1,33 @@
1
+ #!/usr/bin/env node
2
+ // tools/events/upcoming.js — list scheduled events from now forward.
3
+ // Flags: --json | --today (only events starting today) | --days N (next N days)
4
+ // Used by render-today.js, good-morning, wrap-up.
5
+
6
+ const E = require('./lib/events');
7
+
8
+ const args = process.argv.slice(2);
9
+ const asJson = args.includes('--json');
10
+ const onlyToday = args.includes('--today');
11
+ const daysIdx = args.indexOf('--days');
12
+ const days = daysIdx >= 0 ? parseInt(args[daysIdx + 1], 10) : null;
13
+
14
+ let events;
15
+ if (onlyToday) {
16
+ events = E.todayEvents();
17
+ } else if (days) {
18
+ const now = new Date();
19
+ const until = new Date(now.getTime() + days * 86400 * 1000).toISOString();
20
+ events = E.upcoming().filter(e => e.start <= until);
21
+ } else {
22
+ events = E.upcoming();
23
+ }
24
+
25
+ if (asJson) {
26
+ console.log(JSON.stringify(events, null, 2));
27
+ } else {
28
+ if (!events.length) { console.log('(no upcoming events)'); process.exit(0); }
29
+ for (const e of events) {
30
+ const time = e.start.length >= 16 ? e.start.slice(0, 16).replace('T', ' ') : e.start.slice(0, 10);
31
+ console.log(`EVT-${e.id} ${time} [${e.type}/${e.status}] ${e.title}`);
32
+ }
33
+ }
@@ -97,22 +97,33 @@ function renderTaskLine(t, openIds) {
97
97
  return parts.join(' ');
98
98
  }
99
99
 
100
- function buildCallsSection(leads) {
101
- const today = todayStr();
102
- const calls = leads.filter(r => r.call_at && r.call_at.startsWith(today) && ['call_booked', 'replied'].includes(r.status));
100
+ function loadEventsLib() {
101
+ // events.yml is part of the base template, but be defensive in case someone removed it.
102
+ const eventsLib = path.join(ROOT, 'tools/events/lib/events.js');
103
+ if (!fs.existsSync(eventsLib)) return null;
104
+ try { return require(eventsLib); } catch (e) { return null; }
105
+ }
106
+
107
+ function buildCallsSection() {
108
+ // events.yml is the single source of truth for time-based events.
109
+ const events = loadEventsLib();
110
+ if (!events) return null;
111
+ const calls = events.todayEvents();
103
112
  if (calls.length === 0) return null;
104
113
  const lines = ['## Calls & meetings'];
105
- for (const c of calls) {
106
- const name = [c.first_name, c.last_name].filter(Boolean).join(' ') || '(unnamed)';
107
- const time = c.call_at.slice(11, 16) || 'TBD';
108
- const note = c.title ? ` (${c.title})` : '';
109
- lines.push(`- ${time} — ${name} / ${c.company}${note}`);
114
+ for (const e of calls) {
115
+ const time = e.start && e.start.length >= 16 ? e.start.slice(11, 16) : 'TBD';
116
+ lines.push(`- ${time} ${e.title}`);
110
117
  }
111
118
  return lines.join('\n');
112
119
  }
113
120
 
114
121
  function buildPulseSection(leads, metrics) {
115
- if (!leads.length && !Object.keys(metrics).length) return null;
122
+ const events = loadEventsLib();
123
+ const upcomingEvents = events
124
+ ? events.upcoming().filter(e => e.start && e.start.slice(0, 10) > todayStr())
125
+ : [];
126
+ if (!leads.length && !Object.keys(metrics).length && !upcomingEvents.length) return null;
116
127
  const lines = ['## Pulse'];
117
128
  if (leads.length) {
118
129
  const pipeline = leads.filter(r => ['replied', 'call_booked', 'call_done', 'verbal', 'loi'].includes(r.status));
@@ -122,6 +133,10 @@ function buildPulseSection(leads, metrics) {
122
133
  if (metrics.outreach_sent !== undefined) {
123
134
  lines.push(`- Total sent: ${metrics.outreach_sent}${metrics.connections_sent !== undefined ? ` (${metrics.connections_sent} conn + ${metrics.inmails_sent} inmail)` : ''} · ${metrics.responses || 0} replies · ${metrics.discovery_calls || 0} calls · ${metrics.verbal_commitments || 0} verbal`);
124
135
  }
136
+ if (upcomingEvents.length) {
137
+ const fmt = upcomingEvents.slice(0, 5).map(e => `${e.start.slice(0, 10)} ${e.title}`).join(', ');
138
+ lines.push(`- Upcoming events (${upcomingEvents.length}): ${fmt}`);
139
+ }
125
140
  if (leads.length) {
126
141
  const warm = leads.filter(r => r.status === 'replied' && r.replied_at);
127
142
  const stale = warm.filter(r => {
@@ -269,7 +284,7 @@ sections.push(header);
269
284
  const preamble = `> *Your daily surface. Check the boxes in Obsidian, then flush with \`node tools/tasks/flush-today.js\`.*\n> *Underlying source of truth: \`tasks.yml\`. Never hand-edit that file while this doc has unflushed changes.*`;
270
285
  sections.push(preamble);
271
286
 
272
- const callsBlock = buildCallsSection(leads);
287
+ const callsBlock = buildCallsSection();
273
288
  if (callsBlock) sections.push(callsBlock);
274
289
 
275
290
  const pulseBlock = buildPulseSection(leads, metrics);