Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 10 additions & 25 deletions src/mentorSummary.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,21 +14,19 @@ import {
listEventsStartingInRange,
} from "./db/repo.js";
import { log } from "./logger.js";
import {
deleteWeeklySummary,
postWeeklySummary,
} from "./slack/weeklySummary.js";
import { postWeeklySummary } from "./slack/weeklySummary.js";
import { toWeeklySummaryEventInfo } from "./weeklySummary.js";

/**
* The Mentor/Teacher Weekly Summary: if due, deletes the previous post and
* sends a fresh one covering the 14 days starting tomorrow — a rolling
* look-ahead (see upcomingTwoWeekRange), wider than the Team Meeting Weekly
* Summary's one-week span for more lead time on travel and unavailability
* conflicts. Unlike the Team Meeting Weekly Summary, there is no mid-window
* edit-in-place here — a change mid-week shows up corrected on the next
* scheduled post rather than a live edit, since this is an admin-only FYI
* digest, not something people are actively tracking.
* The Mentor/Teacher Weekly Summary: if due, sends a fresh post covering
* the 14 days starting tomorrow — a rolling look-ahead (see
* upcomingTwoWeekRange), wider than the Team Meeting Weekly Summary's
* one-week span for more lead time on travel and unavailability conflicts.
* Every prior post stays, never replaced (see ADR-0014). Unlike the Team
* Meeting Weekly Summary, there is no mid-window edit-in-place here — a
* change mid-week shows up corrected on the next scheduled post rather
* than a live edit, since this is an admin-only FYI digest, not something
* people are actively tracking.
*/
export async function postDueMentorSummary(client: WebClient): Promise<void> {
const calendarId = getSetting("mentor_calendar_id");
Expand All @@ -41,19 +39,6 @@ export async function postDueMentorSummary(client: WebClient): Promise<void> {
const now = new Date();
if (!isWeeklySummaryDue(lastPostedAt, timing, now)) return;

if (mostRecent) {
await deleteWeeklySummary(
client,
mostRecent.channel,
mostRecent.message_ts
).catch((err) =>
log.error("could not delete previous mentor summary", {
mentorSummaryId: mostRecent.id,
error: String(err),
})
);
}

const { start, end } = upcomingTwoWeekRange(now);
const events = listEventsStartingInRange(
start.toISOString(),
Expand Down
22 changes: 8 additions & 14 deletions src/scheduler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ import { log } from "./logger.js";
import {
announceEventEdited,
announceEventRemoved,
lockCheckinPost,
postCheckinPost,
} from "./slack/checkin.js";
import { syncAttendanceFromSlack } from "./slack/attendanceEvents.js";
Expand Down Expand Up @@ -594,7 +595,7 @@ async function postEventAttendanceReport(
}

/**
* Resync, verify, and — only on success — credit hours, delete the
* Resync, verify, and — only on success — credit hours, lock the
* Check-in Post, finalize, and post the Event Attendance Report. Shared by
* the scheduler's normal sweep and the manual `/hawkbot event retry-cutoff`
* command, so a retry is exactly the same flow run again on demand.
Expand Down Expand Up @@ -651,19 +652,12 @@ export async function attemptEventCutoff(
setHoursCredited(event.id, row.user_id, hours);
}

if (event.checkin_channel && event.checkin_message_ts) {
await client.chat
.delete({
channel: event.checkin_channel,
ts: event.checkin_message_ts,
})
.catch((err) =>
log.error("could not delete check-in post", {
eventId: event.id,
error: String(err),
})
);
}
await lockCheckinPost(client, event).catch((err) =>
log.error("could not lock check-in post", {
eventId: event.id,
error: String(err),
})
);
markEventFinalized(event.id);
log.info("event finalized", { eventId: event.id });

Expand Down
47 changes: 43 additions & 4 deletions src/slack/checkin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,14 @@ const REACTION_LEGEND = [
"❌, or anything else — I can't make it (reply in this thread with why, if you want)",
];

/** When, where, and description — shared by every post-title variant of the meeting details. */
function meetingDetailBodyLines(event: EventRow): string[] {
const lines = [formatEventTime(event)];
if (event.location) lines.push(`📍 ${event.location}`);
if (event.description) lines.push(event.description);
return lines;
}

/**
* Title, when, where, and description — the part of the post worth keeping
* even once it's removed. `updateNote`, when given, marks the title line as
Expand All @@ -52,16 +60,27 @@ function meetingDetailsText(event: EventRow, updateNote?: string): string {
const titleLine = updateNote
? `<!channel> ✏️ *${event.title}* _(updated: ${updateNote})_`
: `<!channel> *${event.title}*`;
const lines = [titleLine, formatEventTime(event)];
if (event.location) lines.push(`📍 ${event.location}`);
if (event.description) lines.push(event.description);
return lines.join("\n");
return [titleLine, ...meetingDetailBodyLines(event)].join("\n");
}

function checkinMessageText(event: EventRow): string {
return [meetingDetailsText(event), ...REACTION_LEGEND].join("\n");
}

/**
* The Reaction Cutoff's locked-post text, replacing `checkinMessageText`
* once attendance closes — see ADR-0013. Drops `<!channel>` (nothing left
* to ping about) and the reaction legend entirely (not struck through:
* unlike Calendar Change Handling's "removed" case, there's no value in
* showing what the legend used to say). Meeting details stay exactly as
* `meetingDetailsText` already renders them, since the meeting itself
* still happened.
*/
export function lockedCheckinMessageText(event: EventRow): string {
const titleLine = `✅ *${event.title}* — attendance closed`;
return [titleLine, ...meetingDetailBodyLines(event)].join("\n");
}

/**
* Posts the Event Check-in Post, pre-populates its reactions, and returns
* the channel's current membership (minus the bot itself) to snapshot as
Expand Down Expand Up @@ -153,6 +172,26 @@ async function notifyReactionSeedFailure(
}
}

/**
* The Reaction Cutoff's lock step, replacing the old delete — see
* ADR-0013. Rewrites the Check-in Post in place with
* `lockedCheckinMessageText`; leaves every reaction on the post (seeded
* and human) and the thread (Attendance Notes and all) exactly as they
* are — a bot token can't touch a human's reaction or reply anyway, and
* there's no reason to strip its own seeded ones either.
*/
export async function lockCheckinPost(
client: WebClient,
event: EventRow
): Promise<void> {
if (!event.checkin_channel || !event.checkin_message_ts) return;
await client.chat.update({
channel: event.checkin_channel,
ts: event.checkin_message_ts,
text: lockedCheckinMessageText(event),
});
}

/** One `~old~ → new` line per changed field worth showing a before/after for. */
function changedDetailLines(
previous: EventRow,
Expand Down
28 changes: 10 additions & 18 deletions src/slack/weeklySummary.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
import type { WebClient } from "@slack/web-api";

/**
* Posting, editing, and deleting the Weekly Summary Post and its
* Informational Calendar reply. Deliberately plain — no broadcast (see
* ADR-0005) — each message is always rebuilt and replaced in place, never
* appended to at the Slack API level. The scheduler decides what the
* current full text should be; this module just gets it onto (or off of)
* Posting and editing the Weekly Summary Post and its Informational
* Calendar reply. Deliberately plain — no broadcast (see ADR-0005) — each
* message is always rebuilt and edited in place, never appended to at the
* Slack API level, and never deleted (see ADR-0014). The scheduler decides
* what the current full text should be; this module just gets it onto
* Slack. The Informational reply is the one exception that does thread —
* see postInformationalReply — but is still edited/deleted the same
* generic way as the parent once it exists.
* see postInformationalReply — but is still edited the same generic way
* as the parent once it exists.
*/

export async function postWeeklySummary(
Expand All @@ -31,9 +31,9 @@ export async function postWeeklySummary(

/**
* Posts the Informational Calendar's digest as a threaded reply under a
* Weekly Summary Post. Once posted, it's edited and deleted exactly like
* the parent — via updateWeeklySummary/deleteWeeklySummary below — so no
* separate update/delete function exists for it.
* Weekly Summary Post. Once posted, it's edited exactly like the parent —
* via updateWeeklySummary below — so no separate update function exists
* for it.
*/
export async function postInformationalReply(
client: WebClient,
Expand Down Expand Up @@ -63,11 +63,3 @@ export async function updateWeeklySummary(
): Promise<void> {
await client.chat.update({ channel, ts, text });
}

export async function deleteWeeklySummary(
client: WebClient,
channel: string,
ts: string
): Promise<void> {
await client.chat.delete({ channel, ts });
}
33 changes: 2 additions & 31 deletions src/weeklySummary.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,6 @@ import {
} from "./db/repo.js";
import { log } from "./logger.js";
import {
deleteWeeklySummary,
postInformationalReply,
postWeeklySummary,
updateWeeklySummary,
Expand Down Expand Up @@ -323,8 +322,8 @@ export async function reflectWeeklySummaryChange(
}

/**
* If due, deletes the previous Weekly Summary Post (and its Informational
* reply, if it had one) and posts this week's — then, if the Informational
* If due, posts this week's Weekly Summary Post — alongside every prior
* week's, never replacing them (see ADR-0014) — then, if the Informational
* Calendar is enabled and has anything in range, posts this week's
* Informational reply threaded under it.
*/
Expand All @@ -338,34 +337,6 @@ export async function postDueWeeklySummary(client: WebClient): Promise<void> {
const now = new Date();
if (!isWeeklySummaryDue(lastPostedAt, timing, now)) return;

if (mostRecent) {
await deleteWeeklySummary(
client,
mostRecent.channel,
mostRecent.message_ts
).catch((err) =>
log.error("could not delete previous weekly summary", {
weeklySummaryId: mostRecent.id,
error: String(err),
})
);
if (
mostRecent.informational_channel &&
mostRecent.informational_message_ts
) {
await deleteWeeklySummary(
client,
mostRecent.informational_channel,
mostRecent.informational_message_ts
).catch((err) =>
log.error("could not delete previous informational reply", {
weeklySummaryId: mostRecent.id,
error: String(err),
})
);
}
}

const { start, end } = upcomingWeekRange(now);
const events = listEventsStartingInRange(
start.toISOString(),
Expand Down
67 changes: 67 additions & 0 deletions test/checkin.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { lockedCheckinMessageText } from "../src/slack/checkin.js";
import type { EventRow } from "../src/db/repo.js";

process.env.TZ = "UTC";

const baseEvent: EventRow = {
id: 1,
calendar_event_id: "abc",
calendar_link: null,
source: "google_calendar",
calendar_role: "team_meeting",
title: "Team Meeting",
description: "Regular build season meeting.",
location: "Main Shop",
meeting_type: "hourly",
starts_at: "2026-08-20T18:00:00Z",
ends_at: "2026-08-20T20:00:00Z",
checkin_at: "2026-08-20T14:00:00-04:00",
reaction_cutoff_at: "2026-08-21T00:00:00-04:00",
checkin_channel: "C123",
checkin_message_ts: "1700000000.000100",
checkin_posted_at: "2026-08-20T14:00:00-04:00",
finalized_at: null,
removed_at: null,
verification_failed_at: null,
multiday_parent_id: null,
created_at: "2026-08-01T00:00:00Z",
updated_at: "2026-08-01T00:00:00Z",
};

test("the locked message says attendance is closed", () => {
const text = lockedCheckinMessageText(baseEvent);
assert.match(text, /attendance closed/);
});

test("the locked message drops the @channel mention", () => {
const text = lockedCheckinMessageText(baseEvent);
assert.doesNotMatch(text, /<!channel>/);
});

test("the locked message drops the reaction legend entirely", () => {
const text = lockedCheckinMessageText(baseEvent);
assert.doesNotMatch(text, /React to let the team know/);
assert.doesNotMatch(text, /👍/);
assert.doesNotMatch(text, /🕐/);
assert.doesNotMatch(text, /❌/);
});

test("the locked message keeps the title, time, location, and description", () => {
const text = lockedCheckinMessageText(baseEvent);
assert.match(text, /Team Meeting/);
assert.match(text, /6:00 PM.*8:00 PM/);
assert.match(text, /Main Shop/);
assert.match(text, /Regular build season meeting\./);
});

test("the locked message skips an empty location and description", () => {
const text = lockedCheckinMessageText({
...baseEvent,
location: "",
description: "",
});
assert.doesNotMatch(text, /📍/);
assert.equal(text.split("\n").length, 2);
});
Loading