create-battle-plan 1.3.0 → 1.4.0
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.
- package/package.json +1 -1
- package/template/.claude/commands/good-morning.md +13 -0
- package/template/.claude/commands/wrap-up.md +36 -0
- package/template/CLAUDE.md +135 -240
- package/template/CLAUDE.md.backup-2026-05-11 +310 -0
- package/template/events-archive.yml +5 -0
- package/template/events.yml +5 -0
- package/template/tools/events/add.js +64 -0
- package/template/tools/events/archive.js +55 -0
- package/template/tools/events/due-for-gate.js +31 -0
- package/template/tools/events/lib/events.js +221 -0
- package/template/tools/events/migrate-from-csv.js +90 -0
- package/template/tools/events/upcoming.js +33 -0
- package/template/tools/tasks/render-today.js +25 -10
|
@@ -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
|
|
101
|
-
|
|
102
|
-
const
|
|
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
|
|
106
|
-
const
|
|
107
|
-
|
|
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
|
-
|
|
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(
|
|
287
|
+
const callsBlock = buildCallsSection();
|
|
273
288
|
if (callsBlock) sections.push(callsBlock);
|
|
274
289
|
|
|
275
290
|
const pulseBlock = buildPulseSection(leads, metrics);
|