Skip to content

Commit 78f81c2

Browse files
Count DRI re-assignments via an hourly ledger; add /sponsor quota self-check
Notion doesn't date people-property edits, so PERBot now keeps a `DRI assigned` ledger on each Pipeline deal (one "<user id> <ISO>" line per current DRI), kept current by a new hourly DRI sync (jobs/driSync.ts + sponsorship-dri-sync.yml): new DRIs are stamped when noticed, removed ones dropped, pre-ledger deals backfilled with their creation time, and createPipelineDeal seeds it. The quota audit now counts by these stamps (fallback: creation time), so being added as DRI on an existing deal counts in the week it happened. /sponsor quota (ephemeral): the caller's count for the in-progress window with the deals that counted and how many more they need, plus the roster's standing — same roster/window/rule as the Saturday audit. Claude-Session: https://claude.ai/code/session_01G6RNPBbjLS6V7Drtfkbop2
1 parent a414a3b commit 78f81c2

7 files changed

Lines changed: 265 additions & 24 deletions

File tree

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
name: Sponsorship — Hourly DRI Sync
2+
3+
# Keeps PERBot's DRI ledger (`DRI assigned` on each Pipeline deal) in step with the DRI
4+
# people property: stamps newly-added DRIs with the time they were noticed, drops removed
5+
# ones, backfills pre-ledger deals with their creation time. This is what lets the weekly
6+
# quota audit count re-assignments onto older deals. Idempotent — only changed deals are
7+
# written — so late/double cron fires are harmless.
8+
on:
9+
schedule:
10+
- cron: '30 * * * *' # hourly, offset from the win-post job
11+
workflow_dispatch:
12+
13+
jobs:
14+
dri-sync:
15+
runs-on: ubuntu-latest
16+
concurrency:
17+
group: sponsor-dri-sync
18+
cancel-in-progress: false
19+
steps:
20+
- uses: actions/checkout@v4
21+
22+
- uses: actions/setup-node@v4
23+
with:
24+
node-version: '20'
25+
cache: 'npm'
26+
cache-dependency-path: PERBot/package-lock.json
27+
28+
- name: Install dependencies
29+
working-directory: PERBot
30+
run: npm ci
31+
32+
- name: Sync the DRI ledger
33+
working-directory: PERBot
34+
run: npm run job:sponsor-dri-sync
35+
env:
36+
SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }}
37+
NOTION_API_KEY: ${{ secrets.NOTION_API_KEY }}

‎PERBot/package.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
"job:sponsor-reply": "tsx src/sponsorship/jobs/replyDm.ts",
1919
"job:sponsor-digest": "tsx src/sponsorship/jobs/weeklyDigest.ts",
2020
"job:sponsor-quota": "tsx src/sponsorship/jobs/quotaAudit.ts",
21+
"job:sponsor-dri-sync": "tsx src/sponsorship/jobs/driSync.ts",
2122
"check": "tsc --noEmit"
2223
},
2324
"dependencies": {
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { logger } from '../../utils/logger.js';
2+
import { SponsorNotion } from '../notion.js';
3+
import { PipelineRow } from '../types.js';
4+
5+
/**
6+
* Hourly DRI sync: Notion doesn't record WHEN a person was added to a people property,
7+
* so PERBot keeps its own ledger on each Pipeline deal (`DRI assigned`: one
8+
* "<user id> <ISO>" line per current DRI). Each run diffs every deal's DRI set against
9+
* its ledger — new DRIs are stamped, removed DRIs are dropped, deals whose ledger is
10+
* empty are backfilled with the deal's creation time (the best evidence for assignments
11+
* that predate the ledger). The quota audit + `/sponsor quota` count by these stamps, so
12+
* a re-assignment onto an older deal counts in the week it happened (Arjun's rule,
13+
* 2026-09-11). Granularity is the sync cadence: an assign→unassign inside one hour is
14+
* invisible, which is fine.
15+
*
16+
* Idempotent by construction: only deals whose ledger would change are written.
17+
*/
18+
19+
/** The ledger a deal should have now, or null if it's already correct. */
20+
export function reconcileLedger(deal: PipelineRow, nowIso: string): Record<string, string> | null {
21+
const current = new Set(deal.driUserIds.map((id) => id.toLowerCase()));
22+
const next: Record<string, string> = {};
23+
const ledgerEmpty = Object.keys(deal.driAssignedAt).length === 0;
24+
for (const id of current) {
25+
// First-ever stamp on a deal → the deal's creation time (pre-ledger assignments);
26+
// later additions → now.
27+
next[id] = deal.driAssignedAt[id] ?? (ledgerEmpty && deal.createdTime ? deal.createdTime : nowIso);
28+
}
29+
const before = Object.entries(deal.driAssignedAt).sort().join('|');
30+
const after = Object.entries(next).sort().join('|');
31+
return before === after ? null : next;
32+
}
33+
34+
export async function syncDriLedger(notion: SponsorNotion = new SponsorNotion()): Promise<{ scanned: number; updated: number }> {
35+
const deals = await notion.queryAllDeals();
36+
const nowIso = new Date().toISOString();
37+
let updated = 0;
38+
for (const deal of deals) {
39+
const next = reconcileLedger(deal, nowIso);
40+
if (!next) continue;
41+
await notion.writeDriLedger(deal.id, next);
42+
updated += 1;
43+
logger.info(`DRI sync: ${deal.company || deal.id} → ${Object.keys(next).length} DRI(s) stamped.`);
44+
}
45+
logger.info(`DRI sync: scanned ${deals.length} deals, updated ${updated}.`);
46+
return { scanned: deals.length, updated };
47+
}
48+
49+
// Entrypoint when run directly (GitHub Actions).
50+
const isMain = process.argv[1] && import.meta.url === `file://${process.argv[1]}`;
51+
if (isMain) {
52+
syncDriLedger().catch((err) => {
53+
logger.error('DRI sync job failed.', err);
54+
process.exitCode = 1;
55+
});
56+
}

‎PERBot/src/sponsorship/jobs/quotaAudit.ts‎

Lines changed: 81 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -5,16 +5,17 @@ import { etWallTimeToUtc, isoAddDays, todayIsoET } from '../dates.js';
55
import { fetchSlackDirectory, Indexed, notionUserToSlackId } from '../identity.js';
66
import { SponsorNotion } from '../notion.js';
77
import { NotionUser, PipelineRow, QuotaAuditResult, QuotaRosterMember } from '../types.js';
8+
import { syncDriLedger } from './driSync.js';
89
import { alreadyPosted, makeSlackClient, metadataFor, resolveChannelId, WinMeta } from './shared.js';
910

1011
/**
1112
* Saturday 10 AM ET weekly quota audit: every active member of the 👥 Ops Quota Roster
1213
* (a Notion DB ops leads maintain — no deploy to change who's on the hook) must have
1314
* been assigned as DRI on `config.sponsorship.weeklyQuota` Pipeline deals in the past
14-
* week. "Assigned this week" = the deal's Notion page was created inside the window
15-
* [last Sat 10:00 ET, this Sat 10:00 ET) with the member on its DRI. Every `/sponsor
16-
* add … @person`, `/sponsor claim`, and hand-made Pipeline row counts; co-owned deals
17-
* credit each DRI in full (same rule as the leaderboard).
15+
* week. "Assigned this week" = the member's DRI stamp on the deal (PERBot's `DRI assigned`
16+
* ledger, kept by jobs/driSync.ts; falls back to the deal's creation time) falls inside the
17+
* window [last Sat 10:00 ET, this Sat 10:00 ET). So new deals AND re-assignments onto
18+
* older deals count; co-owned deals credit each DRI in full (same rule as the leaderboard).
1819
*
1920
* Output: (1) one row per member per week upserted into 📋 Weekly Quota Audit — the
2021
* durable record of who met quota and who didn't; (2) ONE channel post (#perbot_spam)
@@ -60,25 +61,45 @@ export function auditWindow(weekEndIso: string = todayIsoET()): AuditWindow {
6061
}
6162

6263
/**
63-
* Pure audit: for each roster member, the deals created inside the window that list
64-
* them as DRI, and whether that count reaches the quota. `deals` may include rows
65-
* outside the window (the Notion query is only lower-bounded) — filtered here.
64+
* The window currently in progress — ends at 10:00 ET on the next Saturday (today, if it's
65+
* Saturday before 10am; next week's if the 10am audit has already run). Powers the
66+
* `/sponsor quota` mid-week self-check so people see the same window Saturday will judge.
67+
*/
68+
export function currentAuditWindow(now: Date = new Date()): AuditWindow {
69+
const todayIso = now.toLocaleDateString('en-CA', { timeZone: 'America/New_York' });
70+
const weekday = now.toLocaleDateString('en-US', { weekday: 'short', timeZone: 'America/New_York' });
71+
const hour = Number(now.toLocaleString('en-US', { hour: '2-digit', hour12: false, timeZone: 'America/New_York' }));
72+
const dow = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'].indexOf(weekday);
73+
let daysToSaturday = (6 - dow + 7) % 7;
74+
if (daysToSaturday === 0 && hour >= AUDIT_HOUR_ET) daysToSaturday = 7;
75+
return auditWindow(isoAddDays(todayIso, daysToSaturday));
76+
}
77+
78+
/** When `userId` was assigned to `deal`: the ledger stamp, else the deal's creation time. */
79+
export function assignedAtIso(deal: PipelineRow, userId: string): string {
80+
return deal.driAssignedAt[userId.toLowerCase()] ?? deal.createdTime;
81+
}
82+
83+
/**
84+
* Pure audit: for each roster member, the deals they were assigned to inside the window
85+
* (current DRI + assignment stamp in range), and whether that count reaches the quota.
86+
* `deals` may include rows outside the window (the Notion query is only lower-bounded).
6687
*/
6788
export function computeQuotaResults(
6889
members: QuotaRosterMember[],
6990
deals: PipelineRow[],
7091
window: AuditWindow,
7192
quota: number
7293
): QuotaAuditResult[] {
73-
const inWindow = deals.filter((d) => {
74-
if (!d.createdTime) return false;
75-
const t = new Date(d.createdTime).getTime();
94+
const inWindow = (iso: string) => {
95+
if (!iso) return false;
96+
const t = new Date(iso).getTime();
7697
return t >= window.start.getTime() && t < window.end.getTime();
77-
});
98+
};
7899
return members.map((member) => {
79-
const mine = inWindow
80-
.filter((d) => d.driUserIds.includes(member.notionUserId))
81-
.sort((a, b) => a.createdTime.localeCompare(b.createdTime));
100+
const mine = deals
101+
.filter((d) => d.driUserIds.includes(member.notionUserId) && inWindow(assignedAtIso(d, member.notionUserId)))
102+
.sort((a, b) => assignedAtIso(a, member.notionUserId).localeCompare(assignedAtIso(b, member.notionUserId)));
82103
return {
83104
member,
84105
weekStartIso: window.weekStartIso,
@@ -90,7 +111,7 @@ export function computeQuotaResults(
90111
});
91112
}
92113

93-
function fmtDate(iso: string): string {
114+
export function fmtDate(iso: string): string {
94115
return new Date(`${iso}T12:00:00Z`).toLocaleDateString('en-US', { month: 'short', day: 'numeric', timeZone: 'UTC' });
95116
}
96117

@@ -111,7 +132,7 @@ export function buildQuotaPost(
111132

112133
const lines = [
113134
`:clipboard: *Weekly sponsorship quota audit* — ${fmtDate(window.weekStartIso)} → ${fmtDate(window.weekEndIso)}`,
114-
`_Quota: ${quota} new sponsor assignment${plural} per ops member (a Pipeline deal opened this week with you as DRI)._`,
135+
`_Quota: ${quota} new sponsor assignment${plural} per ops member (Pipeline deals you were made DRI on this week — new or re-assigned)._`,
115136
];
116137
if (met.length > 0) {
117138
lines.push(`:white_check_mark: *Met (${met.length}/${total}):* ${met.map((r) => `${r.member.name} (${r.deals.length})`).join(' · ')}`);
@@ -128,6 +149,45 @@ export function buildQuotaPost(
128149
return lines.join('\n');
129150
}
130151

152+
/**
153+
* Ephemeral `/sponsor quota` text: the caller's own standing first (if they're on the
154+
* roster), then the whole team's, so anyone can see where things stand before Saturday.
155+
*/
156+
export function formatQuotaStanding(
157+
results: QuotaAuditResult[],
158+
window: AuditWindow,
159+
quota: number,
160+
callerNotionId: string | null
161+
): string {
162+
const header = `*Sponsor quota — week of ${fmtDate(window.weekStartIso)} → ${fmtDate(window.weekEndIso)}* (audited Sat 10am ET, posted to #${config.sponsorship.quotaChannel})`;
163+
const lines = [header];
164+
165+
const mine = callerNotionId ? results.find((r) => r.member.notionUserId === callerNotionId) : undefined;
166+
if (mine) {
167+
const deals = mine.deals.length ? ` — ${mine.deals.map((d) => `<${d.url}|${d.company || 'Untitled'}>`).join(', ')}` : '';
168+
const remaining = quota - mine.deals.length;
169+
lines.push(
170+
mine.met
171+
? `:white_check_mark: *You: ${mine.deals.length}/${quota}* — quota met${deals}`
172+
: `:hourglass_flowing_sand: *You: ${mine.deals.length}/${quota}*${deals}. ${remaining} more assignment${remaining === 1 ? '' : 's'} by Saturday 10am.`
173+
);
174+
} else {
175+
lines.push("_You're not on the Ops Quota Roster (ops leads can add you in Notion)._");
176+
}
177+
178+
const sorted = [...results].sort(
179+
(a, b) => b.deals.length - a.deals.length || a.member.name.localeCompare(b.member.name)
180+
);
181+
if (sorted.length > 0) {
182+
const met = sorted.filter((r) => r.met).length;
183+
lines.push(
184+
`*Team (${met}/${sorted.length} met):* ${sorted.map((r) => `${r.met ? '✅ ' : ''}${r.member.name} ${r.deals.length}/${quota}`).join(' · ')}`
185+
);
186+
}
187+
lines.push('_Counts Pipeline deals you were made DRI on this week (new or re-assigned). `/sponsor claim <company>` or `/sponsor add <company> @you` to add one._');
188+
return lines.join('\n');
189+
}
190+
131191
function quotaMeta(weekEndIso: string): WinMeta {
132192
return { eventType: 'sponsor_quota_audit', key: 'week_end', value: weekEndIso };
133193
}
@@ -166,9 +226,13 @@ export async function runQuotaAudit(
166226
const client = makeSlackClient();
167227
const notion = new SponsorNotion();
168228

229+
// Bring the DRI ledger up to date first so re-assignments since the last hourly sync
230+
// are dated (skipped in dry runs — no writes — so counts may lag by up to an hour).
231+
if (!dryRun) await syncDriLedger(notion);
232+
169233
const [members, deals, notionUsers] = await Promise.all([
170234
notion.queryQuotaRoster(),
171-
notion.queryDealsCreatedSince(window.start.toISOString()),
235+
notion.queryDealsEditedSince(window.start.toISOString()),
172236
notion.listNotionUsers(),
173237
]);
174238
if (members.length === 0) {

‎PERBot/src/sponsorship/notion.ts‎

Lines changed: 47 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,27 @@ function readRelationCount(prop: any): number {
9393
return (prop?.relation ?? []).length;
9494
}
9595

96+
/** Name of the Pipeline rich-text property holding PERBot's DRI ledger. */
97+
export const DRI_LEDGER_PROP = 'DRI assigned';
98+
99+
/** Parse the ledger text ("<userId> <ISO>" per line; malformed lines ignored). */
100+
export function parseDriLedger(text: string): Record<string, string> {
101+
const ledger: Record<string, string> = {};
102+
for (const line of text.split('\n')) {
103+
const m = line.trim().match(/^([0-9a-f-]{32,36})\s+(\d{4}-\d{2}-\d{2}T[^\s]+)$/i);
104+
if (m && !Number.isNaN(Date.parse(m[2]!))) ledger[m[1]!.toLowerCase()] = m[2]!;
105+
}
106+
return ledger;
107+
}
108+
109+
/** Serialize a ledger back to the property text (sorted for stable diffs). */
110+
export function formatDriLedger(ledger: Record<string, string>): string {
111+
return Object.entries(ledger)
112+
.sort(([, a], [, b]) => a.localeCompare(b))
113+
.map(([id, iso]) => `${id} ${iso}`)
114+
.join('\n');
115+
}
116+
96117
function parseBankRow(page: any): BankLeadRow {
97118
const p = page?.properties ?? {};
98119
const contactName = readRichText(p['Contact name']);
@@ -139,6 +160,7 @@ function parsePipelineRow(page: any): PipelineRow {
139160
nextActionDate: readDate(p['Next action date']),
140161
notes: readRichText(p['Notes']),
141162
createdTime: typeof page?.created_time === 'string' ? page.created_time : '',
163+
driAssignedAt: parseDriLedger(readRichText(p[DRI_LEDGER_PROP])),
142164
};
143165
}
144166

@@ -335,6 +357,18 @@ export class SponsorNotion {
335357
Type: { select: { name: input.type } },
336358
Category: { multi_select: input.categories.map((name) => ({ name })) },
337359
DRI: { people: input.driNotionIds.map((id) => ({ id })) },
360+
// Seed the DRI ledger so the assignment is dated even before the hourly sync runs.
361+
[DRI_LEDGER_PROP]: {
362+
rich_text: [
363+
{
364+
text: {
365+
content: formatDriLedger(
366+
Object.fromEntries(input.driNotionIds.map((id) => [id.toLowerCase(), new Date().toISOString()]))
367+
),
368+
},
369+
},
370+
],
371+
},
338372
'Bank source': { relation: [{ id: input.bankPageId }] },
339373
'Next action': { rich_text: [{ text: { content: input.nextAction.slice(0, 200) } }] },
340374
'Next action date': { date: { start: input.nextActionDateIso } },
@@ -402,11 +436,20 @@ export class SponsorNotion {
402436
}
403437

404438
/**
405-
* Deals whose Notion page was created at/after `sinceIso` (an ISO datetime) — feeds the
406-
* weekly quota audit, which counts DRI assignments by when the deal entered the Pipeline.
439+
* Deals edited (which includes created, and re-assigned — the DRI sync stamps the page)
440+
* at/after `sinceIso` — a superset of every deal that can carry an assignment inside the
441+
* quota window, so the audit doesn't have to scan the whole Pipeline.
407442
*/
408-
async queryDealsCreatedSince(sinceIso: string): Promise<PipelineRow[]> {
409-
return this.queryPipeline({ timestamp: 'created_time', created_time: { on_or_after: sinceIso } });
443+
async queryDealsEditedSince(sinceIso: string): Promise<PipelineRow[]> {
444+
return this.queryPipeline({ timestamp: 'last_edited_time', last_edited_time: { on_or_after: sinceIso } });
445+
}
446+
447+
/** Overwrite a deal's DRI ledger (`DRI assigned`) — used by the hourly DRI sync. */
448+
async writeDriLedger(pageId: string, ledger: Record<string, string>): Promise<void> {
449+
await this.client.pages.update({
450+
page_id: pageId,
451+
properties: { [DRI_LEDGER_PROP]: { rich_text: [{ text: { content: formatDriLedger(ledger).slice(0, 1900) } }] } } as any,
452+
});
410453
}
411454

412455
/** Deals flagged Reply pending by the Phase-3 flow — awaiting a DRI DM. */

0 commit comments

Comments
 (0)