From a6c3d23d7b4db17b88cdd470e3bb80c690a284f1 Mon Sep 17 00:00:00 2001 From: Rachel Moore Date: Wed, 19 Aug 2026 10:20:28 -0400 Subject: [PATCH 1/2] Reformat the Event Attendance Report summary to read as a final report MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The old one-line summary ("2 attended (2 hrs each) · ...") didn't clearly signal it was the locked, final report for the meeting. Adds a :calendar: header, the event date/time, credited hours, and a bulleted breakdown with status emoji. formatEventWhen extracts and shares the date/time formatting already used by the Check-in Post, rather than duplicating it. Co-Authored-By: Claude Sonnet 5 --- src/domain/attendance.ts | 48 +++++++++++++++++++++++++++++++++++----- src/scheduler.ts | 3 +++ src/slack/checkin.ts | 19 +++++----------- test/attendance.test.ts | 41 ++++++++++++++++++++++++++++++++-- 4 files changed, 90 insertions(+), 21 deletions(-) diff --git a/src/domain/attendance.ts b/src/domain/attendance.ts index 751e7af..deb3e60 100644 --- a/src/domain/attendance.ts +++ b/src/domain/attendance.ts @@ -308,25 +308,61 @@ export type AttendanceReportRow = { }; /** - * The Event Attendance Report's top-level channel message — kept to a - * couple of lines. States hours per attendee, not a summed total, since - * every Attending person on one Event is credited the same hours. Past - * tense throughout — this posts after the Reaction Cutoff, once responses - * are locked, so it's reporting what happened, not what's expected to. + * Human-readable "when" for an event: full date, plus a clock-time range + * for anything that isn't an all-day meeting. Shared by the Check-in Post + * (`slack/checkin.ts`) and the Event Attendance Report so the same event + * reads the same way in both places. + */ +export function formatEventWhen( + startsAt: Date, + endsAt: Date, + meetingType: MeetingType +): string { + const dateStr = startsAt.toLocaleDateString("en-US", { + weekday: "long", + month: "long", + day: "numeric", + }); + if (meetingType === "all_day") return dateStr; + const timeFmt: Intl.DateTimeFormatOptions = { + hour: "numeric", + minute: "2-digit", + }; + return `${dateStr}, ${startsAt.toLocaleTimeString("en-US", timeFmt)}–${endsAt.toLocaleTimeString("en-US", timeFmt)}`; +} + +/** + * The Event Attendance Report's top-level channel message. Labeled and + * dated up front so it reads unambiguously as the final, locked report for + * this one meeting rather than a status update. `hoursPerAttendee` is the + * credited-hours figure from `hoursCredited()`, not a raw clock duration — + * they only coincide for `hourly` meetings, since `all_day` is a fixed + * setting. Past tense throughout — this posts after the Reaction Cutoff, + * once responses are locked, so it's reporting what happened, not what's + * expected to. */ export function formatAttendanceReportSummary(args: { eventTitle: string; rows: readonly AttendanceReportRow[]; hoursPerAttendee: number; + startsAt: Date; + endsAt: Date; + meetingType: MeetingType; }): string { const attending = args.rows.filter((r) => r.status === "attending").length; const notAttending = args.rows.filter( (r) => r.status === "not_attending" ).length; const noResponse = args.rows.filter((r) => r.status === "no_response").length; + const when = formatEventWhen(args.startsAt, args.endsAt, args.meetingType); return [ + ":calendar: *Meeting Attendance Report*", `*${args.eventTitle}*`, - `${attending} attended (${args.hoursPerAttendee} hrs each) · ${notAttending} didn't attend · ${noResponse} no response`, + `${when} (${args.hoursPerAttendee}h credited)`, + "", + `• ${attending} attended :thumbsup:`, + `• ${notAttending} didn't attend :x:`, + `• ${noResponse} no response :no_entry_sign:`, ].join("\n"); } diff --git a/src/scheduler.ts b/src/scheduler.ts index fc47c43..5456bbf 100644 --- a/src/scheduler.ts +++ b/src/scheduler.ts @@ -342,6 +342,9 @@ async function postEventAttendanceReport( eventTitle: event.title, rows, hoursPerAttendee, + startsAt: new Date(event.starts_at), + endsAt: new Date(event.ends_at), + meetingType: event.meeting_type as MeetingType, }), }); if (posted.channel && posted.ts) { diff --git a/src/slack/checkin.ts b/src/slack/checkin.ts index 5cebde4..6c1f628 100644 --- a/src/slack/checkin.ts +++ b/src/slack/checkin.ts @@ -1,5 +1,6 @@ import type { WebClient } from "@slack/web-api"; import type { EventRow } from "../db/repo.js"; +import { formatEventWhen, type MeetingType } from "../domain/attendance.js"; import { log } from "../logger.js"; import { listHawkBotAdmins } from "./authz.js"; import { @@ -18,19 +19,11 @@ import { fetchChannelRoster } from "./roster.js"; export const PRE_POPULATED_REACTIONS = ["+1", "clock3", "x"] as const; function formatEventTime(event: EventRow): string { - const start = new Date(event.starts_at); - const dateStr = start.toLocaleDateString("en-US", { - weekday: "long", - month: "long", - day: "numeric", - }); - if (event.meeting_type === "all_day") return dateStr; - const end = new Date(event.ends_at); - const timeFmt: Intl.DateTimeFormatOptions = { - hour: "numeric", - minute: "2-digit", - }; - return `${dateStr}, ${start.toLocaleTimeString("en-US", timeFmt)}–${end.toLocaleTimeString("en-US", timeFmt)}`; + return formatEventWhen( + new Date(event.starts_at), + new Date(event.ends_at), + event.meeting_type as MeetingType + ); } /** Strikes through each non-blank line individually, rather than one span across the whole block, so a blank line can't break the formatting partway through. */ diff --git a/test/attendance.test.ts b/test/attendance.test.ts index e1f8bdc..3bdb830 100644 --- a/test/attendance.test.ts +++ b/test/attendance.test.ts @@ -262,7 +262,7 @@ test("a stale recorded row for someone who removed their reaction doesn't fail v assert.deepEqual(result, { ok: true }); }); -test("the report summary states counts and hours-per-attendee, not a summed total", () => { +test("the report summary states counts and credited hours, not a summed total, and reads as a final report", () => { const rows: AttendanceReportRow[] = [ { displayName: "Ada", status: "attending", reactions: ["+1"], note: null }, { @@ -288,10 +288,47 @@ test("the report summary states counts and hours-per-attendee, not a summed tota eventTitle: "Team Meeting", rows, hoursPerAttendee: 2, + startsAt: new Date("2026-08-17T18:30:00Z"), + endsAt: new Date("2026-08-17T20:30:00Z"), + meetingType: "hourly", + }); + assert.equal( + summary, + [ + ":calendar: *Meeting Attendance Report*", + "*Team Meeting*", + "Monday, August 17, 6:30 PM–8:30 PM (2h credited)", + "", + "• 2 attended :thumbsup:", + "• 1 didn't attend :x:", + "• 1 no response :no_entry_sign:", + ].join("\n") + ); +}); + +test("an all-day meeting's report summary shows just the date, with its fixed credited hours", () => { + const rows: AttendanceReportRow[] = [ + { displayName: "Ada", status: "attending", reactions: ["+1"], note: null }, + ]; + const summary = formatAttendanceReportSummary({ + eventTitle: "Regionals", + rows, + hoursPerAttendee: 8, + startsAt: new Date("2026-08-17T00:00:00Z"), + endsAt: new Date("2026-08-18T00:00:00Z"), + meetingType: "all_day", }); assert.equal( summary, - "*Team Meeting*\n2 attended (2 hrs each) · 1 didn't attend · 1 no response" + [ + ":calendar: *Meeting Attendance Report*", + "*Regionals*", + "Monday, August 17 (8h credited)", + "", + "• 1 attended :thumbsup:", + "• 0 didn't attend :x:", + "• 0 no response :no_entry_sign:", + ].join("\n") ); }); From 586913a6f459f570f9b113da50d7678a736667cc Mon Sep 17 00:00:00 2001 From: Rachel Moore Date: Wed, 19 Aug 2026 10:30:05 -0400 Subject: [PATCH 2/2] Blockquote the event title/date in the Attendance Report summary Uses Slack's "> " mrkdwn blockquote so the meeting identity is visually set off from the calendar header and the attendance breakdown. Co-Authored-By: Claude Sonnet 5 --- src/domain/attendance.ts | 4 ++-- test/attendance.test.ts | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/domain/attendance.ts b/src/domain/attendance.ts index deb3e60..8ebdf6f 100644 --- a/src/domain/attendance.ts +++ b/src/domain/attendance.ts @@ -357,8 +357,8 @@ export function formatAttendanceReportSummary(args: { const when = formatEventWhen(args.startsAt, args.endsAt, args.meetingType); return [ ":calendar: *Meeting Attendance Report*", - `*${args.eventTitle}*`, - `${when} (${args.hoursPerAttendee}h credited)`, + `> *${args.eventTitle}*`, + `> ${when} (${args.hoursPerAttendee}h credited)`, "", `• ${attending} attended :thumbsup:`, `• ${notAttending} didn't attend :x:`, diff --git a/test/attendance.test.ts b/test/attendance.test.ts index 3bdb830..2806724 100644 --- a/test/attendance.test.ts +++ b/test/attendance.test.ts @@ -296,8 +296,8 @@ test("the report summary states counts and credited hours, not a summed total, a summary, [ ":calendar: *Meeting Attendance Report*", - "*Team Meeting*", - "Monday, August 17, 6:30 PM–8:30 PM (2h credited)", + "> *Team Meeting*", + "> Monday, August 17, 6:30 PM–8:30 PM (2h credited)", "", "• 2 attended :thumbsup:", "• 1 didn't attend :x:", @@ -322,8 +322,8 @@ test("an all-day meeting's report summary shows just the date, with its fixed cr summary, [ ":calendar: *Meeting Attendance Report*", - "*Regionals*", - "Monday, August 17 (8h credited)", + "> *Regionals*", + "> Monday, August 17 (8h credited)", "", "• 1 attended :thumbsup:", "• 0 didn't attend :x:",