|
| 1 | +import { config } from '../config.js'; |
| 2 | +import { logger } from '../utils/logger.js'; |
| 3 | +import { daysUntil, todayIsoET } from '../sponsorship/dates.js'; |
| 4 | +import { fetchSlackDirectory, notionUserToSlackId } from '../sponsorship/identity.js'; |
| 5 | +import { makeSlackClient } from '../sponsorship/jobs/shared.js'; |
| 6 | +import { OpsTask, OpsTasksNotion } from './notion.js'; |
| 7 | + |
| 8 | +/** |
| 9 | + * Sunday 10 AM ET Ops-tasks digest — DMs ONLY, never a channel post (Arjun's call, |
| 10 | + * 2026-09-11). Each Ops member with an open task in the Ops Tasks database gets one |
| 11 | + * short DM listing exactly their own open tasks: 🔴 on anything past Due, and a |
| 12 | + * "carried N wks" tag on anything assigned before the most recent Saturday. Members |
| 13 | + * with nothing open get nothing (silence is valid). |
| 14 | + * |
| 15 | + * Why "everything open" rather than "assigned this week": a Status-based query can't go |
| 16 | + * silent if a meeting page is created late or a task never gets linked to a meeting, and |
| 17 | + * it is the honest picture anyway. |
| 18 | + * |
| 19 | + * Idempotency: two DST cron triggers, and only the one landing on 10am ET does the work |
| 20 | + * (same pattern as the sponsorship digest). DMs post straight to the user ID. |
| 21 | + */ |
| 22 | + |
| 23 | +/** Seed rows created so Notion would show every member's group; skipped until renamed. */ |
| 24 | +export const PLACEHOLDER_RE = /^rename me/i; |
| 25 | + |
| 26 | +function isSunday10amET(): boolean { |
| 27 | + const now = new Date(); |
| 28 | + const weekday = now.toLocaleDateString('en-US', { weekday: 'long', timeZone: 'America/New_York' }); |
| 29 | + const hour = Number(now.toLocaleString('en-US', { hour: '2-digit', hour12: false, timeZone: 'America/New_York' })); |
| 30 | + return weekday === 'Sunday' && hour === 10; |
| 31 | +} |
| 32 | + |
| 33 | +export function isPlaceholder(task: OpsTask): boolean { |
| 34 | + return task.title === '' || PLACEHOLDER_RE.test(task.title); |
| 35 | +} |
| 36 | + |
| 37 | +/** Whole Saturday meetings that have passed since the task was assigned (0 = fresh). */ |
| 38 | +export function carriedWeeks(task: OpsTask): number { |
| 39 | + if (!task.week) return 0; |
| 40 | + const age = -daysUntil(task.week); |
| 41 | + return age > 0 ? Math.floor(age / 7) : 0; |
| 42 | +} |
| 43 | + |
| 44 | +export function isOverdue(task: OpsTask): boolean { |
| 45 | + return task.due !== null && daysUntil(task.due) < 0; |
| 46 | +} |
| 47 | + |
| 48 | +/** "9/19" — matches how the meeting pages are named. */ |
| 49 | +function shortDate(iso: string): string { |
| 50 | + const [, m, d] = iso.split('-'); |
| 51 | + return `${Number(m)}/${Number(d)}`; |
| 52 | +} |
| 53 | + |
| 54 | +/** Overdue first (oldest due first), then by due date, undated last. */ |
| 55 | +export function sortTasks(tasks: OpsTask[]): OpsTask[] { |
| 56 | + return [...tasks].sort((a, b) => { |
| 57 | + const ao = isOverdue(a) ? 0 : 1; |
| 58 | + const bo = isOverdue(b) ? 0 : 1; |
| 59 | + if (ao !== bo) return ao - bo; |
| 60 | + return (a.due ?? '9999').localeCompare(b.due ?? '9999'); |
| 61 | + }); |
| 62 | +} |
| 63 | + |
| 64 | +export function taskLine(task: OpsTask): string { |
| 65 | + const link = `<${task.url}|${task.title}>`; |
| 66 | + const parts: string[] = []; |
| 67 | + if (task.due) parts.push(`due ${shortDate(task.due)}${isOverdue(task) ? ' 🔴 overdue' : ''}`); |
| 68 | + else parts.push('no due date'); |
| 69 | + const carried = carriedWeeks(task); |
| 70 | + if (carried >= 1) parts.push(`carried ${carried} wk${carried === 1 ? '' : 's'}`); |
| 71 | + return `• ${link} — ${parts.join(' · ')}`; |
| 72 | +} |
| 73 | + |
| 74 | +export function buildDigest(tasks: OpsTask[]): string { |
| 75 | + const sorted = sortTasks(tasks); |
| 76 | + const overdue = sorted.filter(isOverdue).length; |
| 77 | + const header = `:clipboard: *Your open Ops tasks (${sorted.length}${overdue > 0 ? `, ${overdue} overdue` : ''})*`; |
| 78 | + const footer = `_Set Status → Done in Notion as you finish — Saturday's walkthrough runs off this list. <${config.opsTasks.myOpenViewUrl}|My open>_`; |
| 79 | + return [header, ...sorted.map(taskLine), footer].join('\n'); |
| 80 | +} |
| 81 | + |
| 82 | +export async function runOpsDigest(force = process.env.FORCE_OPS_DIGEST?.toLowerCase() === 'true'): Promise<void> { |
| 83 | + if (!force && !isSunday10amET()) { |
| 84 | + logger.info('Ops digest: not Sunday 10am (ET) and not forced — skipping.'); |
| 85 | + return; |
| 86 | + } |
| 87 | + const dryRun = process.env.OPS_DIGEST_DRY_RUN?.toLowerCase() === 'true'; |
| 88 | + |
| 89 | + const notion = new OpsTasksNotion(); |
| 90 | + const open = await notion.queryOpenTasks(); |
| 91 | + const real = open.filter((t) => !isPlaceholder(t)); |
| 92 | + logger.info(`Ops digest (${todayIsoET()}): ${open.length} open rows, ${open.length - real.length} placeholders skipped.`); |
| 93 | + |
| 94 | + // One list per owner; a co-owned task appears in every owner's DM. |
| 95 | + const byOwner = new Map<string, { owner: OpsTask['owners'][number]; tasks: OpsTask[] }>(); |
| 96 | + for (const task of real) { |
| 97 | + if (task.owners.length === 0) { |
| 98 | + logger.warn(`Ops digest: "${task.title}" has no Owner — nobody to DM.`); |
| 99 | + continue; |
| 100 | + } |
| 101 | + for (const owner of task.owners) { |
| 102 | + const entry = byOwner.get(owner.id) ?? { owner, tasks: [] }; |
| 103 | + entry.tasks.push(task); |
| 104 | + byOwner.set(owner.id, entry); |
| 105 | + } |
| 106 | + } |
| 107 | + if (byOwner.size === 0) { |
| 108 | + logger.info('Ops digest: nobody has open tasks — nothing to send.'); |
| 109 | + return; |
| 110 | + } |
| 111 | + |
| 112 | + const client = makeSlackClient(); |
| 113 | + const slackDir = await fetchSlackDirectory(client); |
| 114 | + |
| 115 | + for (const { owner, tasks } of byOwner.values()) { |
| 116 | + const slackUserId = await notionUserToSlackId(client, owner, slackDir); |
| 117 | + const label = owner.name || owner.email || owner.id; |
| 118 | + if (!slackUserId) { |
| 119 | + logger.warn(`Ops digest: no Slack match for ${label} — skipping ${tasks.length} task(s).`); |
| 120 | + continue; |
| 121 | + } |
| 122 | + const text = buildDigest(tasks); |
| 123 | + if (dryRun) { |
| 124 | + logger.info(`Ops digest (dry run) → ${label} (${slackUserId}):\n${text}`); |
| 125 | + continue; |
| 126 | + } |
| 127 | + await client.chat.postMessage({ channel: slackUserId, text, unfurl_links: false }); |
| 128 | + logger.info(`Ops digest: sent ${tasks.length} task(s) to ${label}.`); |
| 129 | + } |
| 130 | +} |
| 131 | + |
| 132 | +// Entrypoint when run directly (GitHub Actions). |
| 133 | +const isMain = process.argv[1] && import.meta.url === `file://${process.argv[1]}`; |
| 134 | +if (isMain) { |
| 135 | + runOpsDigest().catch((err) => { |
| 136 | + logger.error('Ops digest job failed.', err); |
| 137 | + process.exitCode = 1; |
| 138 | + }); |
| 139 | +} |
0 commit comments