Skip to content

Commit aa90cf0

Browse files
Merge pull request #44 from Penn-Electric-Racing/ops-tasks-link-meeting
Ops tasks: link every task to its meeting page
2 parents c980aee + 5aeb30b commit aa90cf0

6 files changed

Lines changed: 195 additions & 6 deletions

File tree

‎PERBot/src/config.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,9 @@ export const config = {
101101
// Ops Tasks database under REV12 Operations (see CLAUDE.md "Notion IDs"): one row per
102102
// action item a member types under their name on the Saturday meeting page.
103103
dataSourceId: optionalString('OPS_TASKS_DS_ID', 'a8afc6ec-29d6-4308-b779-ad4235b35e80'),
104+
// Ops Meetings database (one row per Saturday meeting page, Date property). Tasks link to a
105+
// row via the `Meeting` relation; each page's "This week" table filters on that relation.
106+
meetingsDataSourceId: optionalString('OPS_MEETINGS_DS_ID', 'f0e637cd-db29-4a8d-883f-c8c5603db02f'),
104107
// The database's "My open" view (Owner = me, Status != Done) — linked from the digest DM.
105108
myOpenViewUrl: optionalString(
106109
'OPS_TASKS_MY_OPEN_URL',
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import { test } from 'node:test';
2+
import assert from 'node:assert/strict';
3+
import { pickMeeting, saturdayOnOrAfter } from '../meetings.js';
4+
5+
test('saturdayOnOrAfter: weekdays roll forward, Saturday stays put', () => {
6+
assert.equal(saturdayOnOrAfter('2026-09-13'), '2026-09-19'); // Sunday
7+
assert.equal(saturdayOnOrAfter('2026-09-14'), '2026-09-19'); // Monday
8+
assert.equal(saturdayOnOrAfter('2026-09-18'), '2026-09-19'); // Friday
9+
assert.equal(saturdayOnOrAfter('2026-09-19'), '2026-09-19'); // Saturday
10+
assert.equal(saturdayOnOrAfter('2026-09-19T21:30:00.000Z'), '2026-09-19'); // datetime tolerated
11+
});
12+
13+
const meetings = [
14+
{ id: 'm0919', date: '2026-09-19' },
15+
{ id: 'm0912', date: '2026-09-12' },
16+
{ id: 'm0829', date: '2026-08-29' },
17+
];
18+
19+
test('pickMeeting: a mid-week task belongs to the coming Saturday', () => {
20+
assert.equal(pickMeeting(meetings, '2026-09-14')?.id, 'm0919');
21+
assert.equal(pickMeeting(meetings, '2026-09-13')?.id, 'm0919');
22+
});
23+
24+
test('pickMeeting: a task dated on the Saturday belongs to that meeting', () => {
25+
assert.equal(pickMeeting(meetings, '2026-09-12')?.id, 'm0912');
26+
assert.equal(pickMeeting(meetings, '2026-09-19')?.id, 'm0919');
27+
});
28+
29+
test('pickMeeting: never links to a past meeting or a later week', () => {
30+
assert.equal(pickMeeting(meetings, '2026-09-20'), null); // Sunday after the last page
31+
assert.equal(pickMeeting(meetings, '2026-09-05'), null); // week with no page (9/5 skipped)
32+
assert.equal(pickMeeting([], '2026-09-14'), null);
33+
});
34+
35+
test('pickMeeting: a page stamped with its Sunday creation time still counts for that week', () => {
36+
const sundayStamped = [{ id: 'm0919', date: '2026-09-19' }, { id: 'm0926', date: '2026-09-20T13:00:00.000Z' }];
37+
assert.equal(pickMeeting(sundayStamped, '2026-09-21')?.id, 'm0926'); // Monday → the page created Sunday
38+
assert.equal(pickMeeting(sundayStamped, '2026-09-26')?.id, 'm0926'); // the Saturday itself
39+
assert.equal(pickMeeting(sundayStamped, '2026-09-19')?.id, 'm0919'); // previous week unaffected
40+
assert.equal(pickMeeting(sundayStamped, '2026-09-27'), null); // following week has no page yet
41+
});
42+
43+
test('pickMeeting: a Thursday-stamped page (old repeat schedule) maps to its Saturday', () => {
44+
const odd = [{ id: 'thu', date: '2026-09-17' }];
45+
assert.equal(pickMeeting(odd, '2026-09-14')?.id, 'thu');
46+
assert.equal(pickMeeting(odd, '2026-09-19')?.id, 'thu');
47+
});

‎PERBot/src/opsTasks/assign.ts‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,17 @@ async function createAndAnnounce(
114114
client: WebClient,
115115
opts: { assignerSlackId: string; assigneeSlackIds: string[]; ownerNotionIds: string[]; task: string; dueIso: string | null }
116116
): Promise<{ task: OpsTask; publicText: string }> {
117-
const task = await notion.createTask({ title: opts.task, ownerIds: opts.ownerNotionIds, dueIso: opts.dueIso });
117+
// Link to the week's meeting page so the task shows under "This week" there. The page is
118+
// created ahead of time by the repeating template; if it isn't there yet the task is
119+
// created unlinked and the Sunday digest links it once the page exists.
120+
let meetingId: string | null = null;
121+
try {
122+
meetingId = (await notion.meetingForDate(todayIsoET()))?.id ?? null;
123+
if (!meetingId) logger.warn(`/assign: no Ops Meetings page for the week of ${todayIsoET()} yet — creating "${opts.task}" unlinked.`);
124+
} catch (err) {
125+
logger.warn('/assign: meeting lookup failed — creating the task unlinked.', err);
126+
}
127+
const task = await notion.createTask({ title: opts.task, ownerIds: opts.ownerNotionIds, dueIso: opts.dueIso, meetingId });
118128
const who = opts.assigneeSlackIds.map((id) => `<@${id}>`).join(', ');
119129
const publicText = `:pushpin: <@${opts.assignerSlackId}> assigned ${who}: *${task.title}*${task.due ? ` · due ${shortDate(task.due)}` : ''} · <${task.url}|Open in Notion>`;
120130
for (const assignee of opts.assigneeSlackIds) {

‎PERBot/src/opsTasks/digest.ts‎

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { fetchSlackDirectory, notionUserToSlackId } from '../sponsorship/identit
66
import { makeSlackClient } from '../sponsorship/jobs/shared.js';
77
import { buildDigestBlocks } from './blocks.js';
88
import { isOverdue, sortTasks, taskLine } from './format.js';
9+
import { pickMeeting } from './meetings.js';
910
import { OpsTask, OpsTasksNotion } from './notion.js';
1011

1112
// Formatting helpers live in format.ts (shared with blocks.ts); re-exported for existing importers.
@@ -34,6 +35,37 @@ export function isPlaceholder(task: OpsTask): boolean {
3435
return task.title === '' || PLACEHOLDER_RE.test(task.title);
3536
}
3637

38+
/**
39+
* Link open tasks that have no Meeting relation to the meeting page their date falls in
40+
* (see meetings.ts). Such tasks come from `/assign` runs before the week's page existed and
41+
* from rows added on the old Week-filtered pages; unlinked, they never appear in any
42+
* "This week" table and look carried over on every page. Mutates the passed tasks so the
43+
* DMs sent right after use the linked meeting's date for "carried N wks". Returns the count.
44+
*/
45+
export async function linkOrphanTasks(notion: OpsTasksNotion, tasks: OpsTask[], dryRun = false): Promise<number> {
46+
const orphans = tasks.filter((t) => t.meetingIds.length === 0 && t.week);
47+
if (orphans.length === 0) return 0;
48+
const meetings = await notion.listMeetings();
49+
let linked = 0;
50+
for (const task of orphans) {
51+
const meeting = pickMeeting(meetings, task.week!);
52+
if (!meeting) {
53+
logger.info(`Ops digest: "${task.title}" (dated ${task.week}) has no meeting page to link to yet.`);
54+
continue;
55+
}
56+
try {
57+
if (!dryRun) await notion.linkMeeting(task, meeting.id);
58+
task.meetingIds.push(meeting.id);
59+
task.week = meeting.date;
60+
linked++;
61+
} catch (err) {
62+
logger.warn(`Ops digest: could not link "${task.title}" to meeting ${meeting.date}.`, err);
63+
}
64+
}
65+
logger.info(`Ops digest: linked ${linked}/${orphans.length} unlinked task(s) to their meeting page${dryRun ? ' (dry run)' : ''}.`);
66+
return linked;
67+
}
68+
3769
export function buildDigest(tasks: OpsTask[]): string {
3870
const sorted = sortTasks(tasks);
3971
const overdue = sorted.filter(isOverdue).length;
@@ -55,6 +87,7 @@ export async function runOpsDigest(force = process.env.FORCE_OPS_DIGEST?.toLower
5587
const open = await notion.queryOpenTasks();
5688
const real = open.filter((t) => !isPlaceholder(t));
5789
logger.info(`Ops digest (${todayIsoET()}): ${open.length} open rows, ${open.length - real.length} placeholders skipped.`);
90+
await linkOrphanTasks(notion, real, dryRun);
5891

5992
// One list per owner; a co-owned task appears in every owner's DM.
6093
const byOwner = new Map<string, { owner: OpsTask['owners'][number]; tasks: OpsTask[] }>();

‎PERBot/src/opsTasks/meetings.ts‎

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { isoAddDays } from '../sponsorship/dates.js';
2+
3+
/**
4+
* Which Saturday meeting page a task belongs to. Pure helpers (no Notion) so they're
5+
* testable; `OpsTasksNotion` supplies the meeting list.
6+
*
7+
* Rule: a task dated D (created / assigned on D) belongs to the first meeting on or after
8+
* D, up to and including the Saturday of that week. So something assigned from Slack on
9+
* Monday lands in the coming Saturday's "This week" table instead of looking carried, and
10+
* something typed on the Saturday itself belongs to that day's meeting.
11+
*/
12+
13+
export interface OpsMeeting {
14+
id: string;
15+
/** YYYY-MM-DD from the meeting row's Date property. */
16+
date: string;
17+
}
18+
19+
/** The Saturday on or after `iso` (YYYY-MM-DD); `iso` itself when it is a Saturday. */
20+
export function saturdayOnOrAfter(iso: string): string {
21+
const day = iso.slice(0, 10);
22+
const dow = new Date(`${day}T12:00:00Z`).getUTCDay();
23+
return isoAddDays(day, (6 - dow + 7) % 7);
24+
}
25+
26+
/**
27+
* The meeting a task anchored at `anchorIso` belongs to: the meeting page whose Date falls
28+
* in the same Sunday–Saturday week as the anchor (earliest such page if several). Matching
29+
* by week, not exact date, tolerates the repeating template stamping Date with the page's
30+
* creation moment (Sunday morning) before the lead sets it to the Saturday. `null` when no
31+
* page for that week exists yet.
32+
*/
33+
export function pickMeeting(meetings: OpsMeeting[], anchorIso: string): OpsMeeting | null {
34+
const week = saturdayOnOrAfter(anchorIso);
35+
const candidates = meetings
36+
.filter((m) => saturdayOnOrAfter(m.date) === week)
37+
.sort((a, b) => a.date.localeCompare(b.date));
38+
return candidates[0] ?? null;
39+
}

‎PERBot/src/opsTasks/notion.ts‎

Lines changed: 62 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,20 @@
11
import { Client } from '@notionhq/client';
22
import { config } from '../config.js';
33
import { logger } from '../utils/logger.js';
4+
import { OpsMeeting, pickMeeting } from './meetings.js';
45

56
/**
67
* Notion access for the Ops Tasks database (child of "REV12 Operations"). Reads feed the
78
* Sunday digest; the small writers below back the digest's Slack buttons (Done / In
89
* progress / Push a week) so members can update from Slack instead of opening Notion.
910
* One row = one action item a member typed under their own name on a Saturday meeting
1011
* page. Schema: Task (title) · Owner (person) · Status (Not started / In progress /
11-
* Done) · Due (date, convention = next Saturday) · Week (date, the Saturday it was
12-
* assigned) · Created. Only Status matters for "open": anything not Done is open.
12+
* Done) · Due (date, convention = next Saturday) · Meeting (relation → Ops Meetings row;
13+
* the meeting page's "This week" table is `Meeting contains this page`, its "Carried over"
14+
* table is `Status ≠ Done AND Meeting does not contain this page`) · Week (legacy date) ·
15+
* Created. Only Status matters for "open": anything not Done is open. A task with NO
16+
* Meeting never appears in any "This week" table and looks carried on every page, so the
17+
* Slack writers link it (see `meetingForDate`) and the digest relinks stragglers.
1318
*/
1419

1520
export interface OpsTaskOwner {
@@ -32,6 +37,8 @@ export interface OpsTask {
3237
*/
3338
week: string | null;
3439
owners: OpsTaskOwner[];
40+
/** Ops Meetings row ids in the Meeting relation (empty = not linked to any meeting page). */
41+
meetingIds: string[];
3542
}
3643

3744
function readTitle(prop: any): string {
@@ -58,6 +65,9 @@ function readCreated(page: any): string | null {
5865
const t = page?.created_time;
5966
return typeof t === 'string' ? t.slice(0, 10) : null;
6067
}
68+
function readRelationIds(prop: any): string[] {
69+
return (prop?.relation ?? []).map((r: any) => r?.id).filter((id: any) => typeof id === 'string');
70+
}
6171
function readOwners(prop: any): OpsTaskOwner[] {
6272
return (prop?.people ?? [])
6373
.filter((p: any) => typeof p?.id === 'string')
@@ -79,9 +89,15 @@ export function parseOpsTask(page: any): OpsTask {
7989
due: readDate(p['Due']),
8090
week: readRollupDate(p['Meeting date']) ?? readDate(p['Week']) ?? readCreated(page),
8191
owners: readOwners(p['Owner']),
92+
meetingIds: readRelationIds(p['Meeting']),
8293
};
8394
}
8495

96+
function parseMeeting(page: any): OpsMeeting | null {
97+
const date = readDate(page?.properties?.['Date']);
98+
return date ? { id: page.id, date } : null;
99+
}
100+
85101
export class OpsTasksNotion {
86102
private client: Client;
87103

@@ -106,19 +122,52 @@ export class OpsTasksNotion {
106122
return tasks;
107123
}
108124

109-
/** Create a task (powers `/assign`): Status = Not started, Owner(s), optional Due. Not linked to a meeting page. */
110-
async createTask(input: { title: string; ownerIds: string[]; dueIso: string | null }): Promise<OpsTask> {
125+
/** Every Ops Meetings row with a Date, oldest first (a handful of rows — no need to filter server-side). */
126+
async listMeetings(): Promise<OpsMeeting[]> {
127+
const meetings: OpsMeeting[] = [];
128+
let cursor: string | undefined;
129+
do {
130+
const response: any = await this.client.dataSources.query({
131+
data_source_id: config.opsTasks.meetingsDataSourceId,
132+
filter: { property: 'Date', date: { is_not_empty: true } },
133+
sorts: [{ property: 'Date', direction: 'ascending' }],
134+
page_size: 100,
135+
start_cursor: cursor,
136+
});
137+
for (const page of response.results ?? []) {
138+
const m = parseMeeting(page);
139+
if (m) meetings.push(m);
140+
}
141+
cursor = response.has_more ? response.next_cursor ?? undefined : undefined;
142+
} while (cursor);
143+
return meetings;
144+
}
145+
146+
/** The meeting page a task dated `anchorIso` belongs to (see meetings.ts), or null if that page doesn't exist yet. */
147+
async meetingForDate(anchorIso: string): Promise<OpsMeeting | null> {
148+
return pickMeeting(await this.listMeetings(), anchorIso);
149+
}
150+
151+
/**
152+
* Create a task (powers `/assign`): Status = Not started, Owner(s), optional Due, and the
153+
* Meeting relation when the caller found the week's page (so it shows in that page's
154+
* "This week" table instead of looking carried over).
155+
*/
156+
async createTask(input: { title: string; ownerIds: string[]; dueIso: string | null; meetingId?: string | null }): Promise<OpsTask> {
111157
const properties: Record<string, any> = {
112158
Task: { title: [{ text: { content: input.title.slice(0, 200) } }] },
113159
Owner: { people: input.ownerIds.map((id) => ({ id })) },
114160
Status: { status: { name: 'Not started' } },
115161
};
116162
if (input.dueIso) properties['Due'] = { date: { start: input.dueIso } };
163+
if (input.meetingId) properties['Meeting'] = { relation: [{ id: input.meetingId }] };
117164
const page: any = await this.client.pages.create({
118165
parent: { type: 'data_source_id', data_source_id: config.opsTasks.dataSourceId },
119166
properties: properties as any,
120167
});
121-
logger.info(`Ops task created: "${input.title}" → ${input.ownerIds.length} owner(s), due ${input.dueIso ?? 'none'}.`);
168+
logger.info(
169+
`Ops task created: "${input.title}" → ${input.ownerIds.length} owner(s), due ${input.dueIso ?? 'none'}, meeting ${input.meetingId ?? 'none'}.`
170+
);
122171
return parseOpsTask(page);
123172
}
124173

@@ -134,6 +183,14 @@ export class OpsTasksNotion {
134183
logger.info(`Ops task ${pageId} → Status ${status}.`);
135184
}
136185

186+
/** Add a meeting page to the task's Meeting relation (keeps existing links; no-op if already linked). */
187+
async linkMeeting(task: OpsTask, meetingId: string): Promise<void> {
188+
if (task.meetingIds.includes(meetingId)) return;
189+
const relation = [...task.meetingIds, meetingId].map((id) => ({ id }));
190+
await this.client.pages.update({ page_id: task.id, properties: { Meeting: { relation } } as any });
191+
logger.info(`Ops task ${task.id} → linked to meeting ${meetingId}.`);
192+
}
193+
137194
/** Set the Due date (YYYY-MM-DD). */
138195
async setDue(pageId: string, dueIso: string): Promise<void> {
139196
await this.client.pages.update({ page_id: pageId, properties: { Due: { date: { start: dueIso } } } as any });

0 commit comments

Comments
 (0)