diff --git a/src/domain/attendance.ts b/src/domain/attendance.ts index 751e7af..8ebdf6f 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 [ - `*${args.eventTitle}*`, - `${attending} attended (${args.hoursPerAttendee} hrs each) · ${notAttending} didn't attend · ${noResponse} no response`, + ":calendar: *Meeting Attendance Report*", + `> *${args.eventTitle}*`, + `> ${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..2806724 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") ); });