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
37 changes: 34 additions & 3 deletions src/domain/weeklySummary.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,8 @@ export type WeeklySummaryEventInfo = {
*/
endsAt: Date;
location: string;
/** The Event's own `htmlLink`, if any — see domain/calendar.ts's MappedEvent. */
calendarLink: string | null;
};

export const DATE_FMT: Intl.DateTimeFormatOptions = {
Expand All @@ -158,9 +160,16 @@ function formatWhen(info: WeeklySummaryEventInfo): string {
return `${info.startsAt.toLocaleDateString("en-US", DATE_FMT)} – ${inclusiveEnd.toLocaleDateString("en-US", DATE_FMT)}`;
}

/** Bolded, and linked to the calendar event when a link is known. */
function linkedTitle(info: WeeklySummaryEventInfo): string {
return info.calendarLink
? `<${info.calendarLink}|*${info.title}*>`
: `*${info.title}*`;
}

/** One event's line: title, when, and location — the building block every render case shares. */
export function formatWeeklySummaryLine(info: WeeklySummaryEventInfo): string {
const parts = [`*${info.title}*`, formatWhen(info)];
const parts = [linkedTitle(info), formatWhen(info)];
if (info.location) parts.push(`📍 ${info.location}`);
return parts.join(" — ");
}
Expand Down Expand Up @@ -219,33 +228,55 @@ export type WeeklySummaryLineEntry = {
text: string;
};

/**
* The standard Google Calendar "add this calendar" URL for a calendar id, or
* `undefined` when there's no id (a calendar setting left unconfigured) —
* every caller can pass a `getSetting(...)` result straight through without
* its own null check. Whether the link actually grants access depends
* entirely on that calendar's own sharing settings in Google Calendar —
* hawk-bot has no say in that, it just builds the URL a person would need.
*/
export function calendarSubscribeLink(
calendarId: string | null | undefined
): string | undefined {
if (!calendarId) return undefined;
return `https://calendar.google.com/calendar/u/0/r?cid=${encodeURIComponent(calendarId)}`;
}

/**
* Assembles the full message body, sorted chronologically regardless of
* input order. `label` and `emptyText` default to the Team Meeting Weekly
* Summary's own wording; the Informational reply and Mentor/Teacher Weekly
* Summary override `label` so each reads as its own digest rather than a
* repeat of "This Week".
* repeat of "This Week". `subscribeLink`, when given, appends a footer line
* — every rebuild passes it again, same as every other part of the message,
* since a `chat.update` always replaces the full text.
*/
export function assembleWeeklySummaryMessage(args: {
weekStart: Date;
weekEnd: Date;
entries: readonly WeeklySummaryLineEntry[];
label?: string;
emptyText?: string;
subscribeLink?: string;
}): string {
const lastDay = new Date(args.weekEnd.getTime() - DAY_MS);
const label = args.label ?? "This Week";
const header = `*${label}* — ${args.weekStart.toLocaleDateString("en-US", DATE_FMT)} to ${lastDay.toLocaleDateString("en-US", DATE_FMT)}`;
const footer = args.subscribeLink
? [`📅 <${args.subscribeLink}|Subscribe to this calendar>`]
: [];

if (args.entries.length === 0) {
return [
header,
args.emptyText ?? "_Nothing on the calendar this week._",
...footer,
].join("\n\n");
}

const sorted = [...args.entries].sort(
(a, b) => a.sortKey.getTime() - b.sortKey.getTime()
);
return [header, ...sorted.map((e) => e.text)].join("\n\n");
return [header, ...sorted.map((e) => e.text), ...footer].join("\n\n");
}
2 changes: 2 additions & 0 deletions src/mentorSummary.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { WebClient } from "@slack/web-api";
import {
assembleWeeklySummaryMessage,
calendarSubscribeLink,
formatWeeklySummaryLine,
isWeeklySummaryDue,
resolveWeeklySummaryTiming,
Expand Down Expand Up @@ -56,6 +57,7 @@ export async function postDueMentorSummary(client: WebClient): Promise<void> {
entries,
label: "Mentor/Teacher Calendar",
emptyText: "_Nothing on the Mentor/Teacher Calendar these two weeks._",
subscribeLink: calendarSubscribeLink(calendarId),
});

const { channel, ts } = await postWeeklySummary(client, channelId, text);
Expand Down
15 changes: 11 additions & 4 deletions src/slack/checkin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,13 @@ function meetingDetailBodyLines(event: EventRow): string[] {
return lines;
}

/** Bolded, and linked to the calendar event when the Event has one. */
function linkedTitle(event: EventRow): string {
return event.calendar_link
? `<${event.calendar_link}|*${event.title}*>`
: `*${event.title}*`;
}

/**
* 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 @@ -58,8 +65,8 @@ function meetingDetailBodyLines(event: EventRow): string[] {
*/
function meetingDetailsText(event: EventRow, updateNote?: string): string {
const titleLine = updateNote
? `<!channel> ✏️ *${event.title}* _(updated: ${updateNote})_`
: `<!channel> *${event.title}*`;
? `<!channel> ✏️ ${linkedTitle(event)} _(updated: ${updateNote})_`
: `<!channel> ${linkedTitle(event)}`;
return [titleLine, ...meetingDetailBodyLines(event)].join("\n");
}

Expand All @@ -77,7 +84,7 @@ function checkinMessageText(event: EventRow): string {
* still happened.
*/
export function lockedCheckinMessageText(event: EventRow): string {
const titleLine = `✅ *${event.title}* — attendance closed`;
const titleLine = `✅ ${linkedTitle(event)} — attendance closed`;
return [titleLine, ...meetingDetailBodyLines(event)].join("\n");
}

Expand Down Expand Up @@ -294,7 +301,7 @@ export async function announceEventRemoved(
channel: event.checkin_channel,
ts: event.checkin_message_ts,
text: [
`🚫 *${event.title}* — this meeting has been removed.`,
`🚫 ${linkedTitle(event)} — this meeting has been removed.`,
"",
strikethroughLines(meetingDetailsText(event)),
].join("\n"),
Expand Down
43 changes: 37 additions & 6 deletions src/weeklySummary.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { WebClient } from "@slack/web-api";
import {
assembleWeeklySummaryMessage,
calendarSubscribeLink,
formatWeeklySummaryLine,
isWeeklySummaryDue,
isWithinWeek,
Expand Down Expand Up @@ -48,6 +49,7 @@ export function toWeeklySummaryEventInfo(
startsAt: new Date(row.starts_at),
endsAt: new Date(row.ends_at),
location: row.location,
calendarLink: row.calendar_link,
};
}

Expand All @@ -61,15 +63,25 @@ type SummaryItemSnapshot = Pick<
| "snapshot_location"
>;

/**
* `calendarLink` isn't itself part of the stored snapshot (the snapshot
* table only tracks the fields Weekly Summary Change Reflection diffs) — it
* comes from the same underlying Event's current `calendar_link` instead,
* since a Google Calendar event's own link doesn't change when its fields
* do, so it's exactly as valid for the "as first shown" struck-through side
* of an edit as for the current side.
*/
function snapshotToEventInfo(
item: SummaryItemSnapshot
item: SummaryItemSnapshot,
calendarLink: string | null
): WeeklySummaryEventInfo {
return {
title: item.snapshot_title,
meetingType: item.snapshot_meeting_type,
startsAt: new Date(item.snapshot_starts_at),
endsAt: new Date(item.snapshot_ends_at),
location: item.snapshot_location,
calendarLink,
};
}

Expand Down Expand Up @@ -127,16 +139,22 @@ function buildEntriesFromItems(
): WeeklySummaryLineEntry[] {
const entries: WeeklySummaryLineEntry[] = [];
for (const item of items) {
const snapshotInfo = snapshotToEventInfo(item);
const event = item.removed ? undefined : getEvent(item.event_id);
// Fetched even when already `removed` — the row (and its
// `calendar_link`) still exists, only `removed_at` gets set.
const liveEvent = getEvent(item.event_id);
const snapshotInfo = snapshotToEventInfo(
item,
liveEvent?.calendar_link ?? null
);

if (!event) {
if (item.removed || !liveEvent) {
entries.push({
sortKey: snapshotInfo.startsAt,
text: renderRemovedLine(snapshotInfo),
});
continue;
}
const event = liveEvent;

const currentInfo = toWeeklySummaryEventInfo(event);
if (item.added_mid_week) {
Expand Down Expand Up @@ -172,6 +190,9 @@ async function rebuildAndUpdateWeeklySummary(
weekStart: new Date(summary.week_start),
weekEnd: new Date(summary.week_end),
entries,
subscribeLink: calendarSubscribeLink(
getSetting("team_meeting_calendar_id")
),
});
await updateWeeklySummary(client, summary.channel, summary.message_ts, text);
}
Expand All @@ -191,6 +212,9 @@ async function rebuildAndUpdateInformationalReply(
weekEnd: new Date(summary.week_end),
entries,
label: "Informational Calendar",
subscribeLink: calendarSubscribeLink(
getSetting("informational_calendar_id")
),
});
await updateWeeklySummary(
client,
Expand All @@ -213,7 +237,8 @@ async function reflectInformationalReplyChange(
event: EventRow,
kind: "changed" | "removed"
): Promise<void> {
if (!getSetting("informational_calendar_id")) return;
const calendarId = getSetting("informational_calendar_id");
if (!calendarId) return;

const summary = getMostRecentWeeklySummary();
if (!summary) return;
Expand Down Expand Up @@ -243,6 +268,7 @@ async function reflectInformationalReplyChange(
weekEnd: range.end,
entries,
label: "Informational Calendar",
subscribeLink: calendarSubscribeLink(calendarId),
});
await postInformationalReply(
client,
Expand Down Expand Up @@ -352,6 +378,9 @@ export async function postDueWeeklySummary(client: WebClient): Promise<void> {
weekStart: start,
weekEnd: end,
entries,
subscribeLink: calendarSubscribeLink(
getSetting("team_meeting_calendar_id")
),
});

const { channel, ts } = await postWeeklySummary(client, channelId, text);
Expand All @@ -373,7 +402,8 @@ export async function postDueWeeklySummary(client: WebClient): Promise<void> {
eventCount: events.length,
});

if (getSetting("informational_calendar_id")) {
const informationalCalendarId = getSetting("informational_calendar_id");
if (informationalCalendarId) {
const infoEvents = listEventsStartingInRange(
start.toISOString(),
end.toISOString(),
Expand All @@ -389,6 +419,7 @@ export async function postDueWeeklySummary(client: WebClient): Promise<void> {
weekEnd: end,
entries: infoEntries,
label: "Informational Calendar",
subscribeLink: calendarSubscribeLink(informationalCalendarId),
});
await postInformationalReply(client, channel, ts, infoText)
.then(({ channel: replyChannel, ts: replyTs }) => {
Expand Down
16 changes: 16 additions & 0 deletions test/checkin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,3 +65,19 @@ test("the locked message skips an empty location and description", () => {
assert.doesNotMatch(text, /📍/);
assert.equal(text.split("\n").length, 2);
});

test("the locked message links the title when the Event has a calendar link", () => {
const text = lockedCheckinMessageText({
...baseEvent,
calendar_link: "https://calendar.google.com/event?eid=abc123",
});
assert.match(
text,
/^✅ <https:\/\/calendar\.google\.com\/event\?eid=abc123\|\*Team Meeting\*> — attendance closed/
);
});

test("the locked message shows a plain bolded title when there's no calendar link", () => {
const text = lockedCheckinMessageText(baseEvent);
assert.match(text, /^✅ \*Team Meeting\* — attendance closed/);
});
56 changes: 56 additions & 0 deletions test/weeklySummary.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { test } from "node:test";
import {
DEFAULT_WEEKLY_SUMMARY_TIMING,
assembleWeeklySummaryMessage,
calendarSubscribeLink,
formatWeeklySummaryLine,
isWeeklySummaryDue,
isWithinWeek,
Expand Down Expand Up @@ -150,6 +151,7 @@ const hourlyInfo: WeeklySummaryEventInfo = {
startsAt: new Date("2026-01-06T18:00:00Z"),
endsAt: new Date("2026-01-06T20:00:00Z"),
location: "Room 204",
calendarLink: null,
};

const allDayInfo: WeeklySummaryEventInfo = {
Expand All @@ -158,6 +160,7 @@ const allDayInfo: WeeklySummaryEventInfo = {
startsAt: new Date("2026-01-10T00:00:00Z"),
endsAt: new Date("2026-01-11T00:00:00Z"),
location: "",
calendarLink: null,
};

const multiDayInfo: WeeklySummaryEventInfo = {
Expand All @@ -166,6 +169,7 @@ const multiDayInfo: WeeklySummaryEventInfo = {
startsAt: new Date("2026-03-05T00:00:00Z"),
endsAt: new Date("2026-03-08T00:00:00Z"), // exclusive, per Google's convention
location: "Convention Center",
calendarLink: null,
};

test("an Hourly line shows the date, time range, and location", () => {
Expand Down Expand Up @@ -272,3 +276,55 @@ test("changed fields names exactly the displayed fields that differ", () => {
"location",
]);
});

test("a line links the title when the Event has a calendar link", () => {
const linked: WeeklySummaryEventInfo = {
...hourlyInfo,
calendarLink: "https://calendar.google.com/event?eid=abc123",
};
const line = formatWeeklySummaryLine(linked);
assert.match(
line,
/^<https:\/\/calendar\.google\.com\/event\?eid=abc123\|\*Team Meeting\*>/
);
});

test("a line shows a plain bolded title when there's no calendar link", () => {
const line = formatWeeklySummaryLine(hourlyInfo);
assert.match(line, /^\*Team Meeting\*/);
assert.doesNotMatch(line, /</);
});

test("the subscribe link is the standard Google Calendar 'add this calendar' URL", () => {
assert.equal(
calendarSubscribeLink("team@group.calendar.google.com"),
"https://calendar.google.com/calendar/u/0/r?cid=team%40group.calendar.google.com"
);
});

test("no calendar id means no subscribe link", () => {
assert.equal(calendarSubscribeLink(null), undefined);
assert.equal(calendarSubscribeLink(undefined), undefined);
});

test("a subscribe link appends a footer line to the assembled message", () => {
const message = assembleWeeklySummaryMessage({
weekStart: new Date("2026-01-05T00:00:00Z"),
weekEnd: new Date("2026-01-12T00:00:00Z"),
entries: [],
subscribeLink: "https://calendar.google.com/calendar/u/0/r?cid=abc",
});
assert.match(
message,
/📅 <https:\/\/calendar\.google\.com\/calendar\/u\/0\/r\?cid=abc\|Subscribe to this calendar>$/
);
});

test("no subscribe link means no footer line", () => {
const message = assembleWeeklySummaryMessage({
weekStart: new Date("2026-01-05T00:00:00Z"),
weekEnd: new Date("2026-01-12T00:00:00Z"),
entries: [],
});
assert.doesNotMatch(message, /📅/);
});
Loading