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
50 changes: 43 additions & 7 deletions src/domain/attendance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
}

Expand Down
3 changes: 3 additions & 0 deletions src/scheduler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
19 changes: 6 additions & 13 deletions src/slack/checkin.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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. */
Expand Down
41 changes: 39 additions & 2 deletions test/attendance.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
{
Expand All @@ -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")
);
});

Expand Down
Loading