From 558e4d77fba5af576d96913de8dd9105f2fa8050 Mon Sep 17 00:00:00 2001 From: Rachel Moore Date: Wed, 19 Aug 2026 19:57:40 -0400 Subject: [PATCH] Link check-in and summary titles to their calendar events; add calendar-subscribe links Check-in Post title (original, edited, locked, removed variants) links to the Event's own calendar_link when present. Weekly Summary, Informational Reply, and Mentor/Teacher Summary lines link each title to its Event, and each digest gets a footer link to subscribe to its underlying Google Calendar. calendarLink threads through WeeklySummaryEventInfo; a removed item's link comes from the still-present Event row (calendar_link doesn't change across edits), not a schema migration. --- src/domain/weeklySummary.ts | 37 ++++++++++++++++++++++-- src/mentorSummary.ts | 2 ++ src/slack/checkin.ts | 15 +++++++--- src/weeklySummary.ts | 43 ++++++++++++++++++++++++---- test/checkin.test.ts | 16 +++++++++++ test/weeklySummary.test.ts | 56 +++++++++++++++++++++++++++++++++++++ 6 files changed, 156 insertions(+), 13 deletions(-) diff --git a/src/domain/weeklySummary.ts b/src/domain/weeklySummary.ts index 85ba94b..0480fdc 100644 --- a/src/domain/weeklySummary.ts +++ b/src/domain/weeklySummary.ts @@ -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 = { @@ -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(" — "); } @@ -219,12 +228,29 @@ 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; @@ -232,20 +258,25 @@ export function assembleWeeklySummaryMessage(args: { 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"); } diff --git a/src/mentorSummary.ts b/src/mentorSummary.ts index dd3166e..9ecb2a4 100644 --- a/src/mentorSummary.ts +++ b/src/mentorSummary.ts @@ -1,6 +1,7 @@ import type { WebClient } from "@slack/web-api"; import { assembleWeeklySummaryMessage, + calendarSubscribeLink, formatWeeklySummaryLine, isWeeklySummaryDue, resolveWeeklySummaryTiming, @@ -56,6 +57,7 @@ export async function postDueMentorSummary(client: WebClient): Promise { 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); diff --git a/src/slack/checkin.ts b/src/slack/checkin.ts index 2372a9c..136c221 100644 --- a/src/slack/checkin.ts +++ b/src/slack/checkin.ts @@ -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 @@ -58,8 +65,8 @@ function meetingDetailBodyLines(event: EventRow): string[] { */ function meetingDetailsText(event: EventRow, updateNote?: string): string { const titleLine = updateNote - ? ` ✏️ *${event.title}* _(updated: ${updateNote})_` - : ` *${event.title}*`; + ? ` ✏️ ${linkedTitle(event)} _(updated: ${updateNote})_` + : ` ${linkedTitle(event)}`; return [titleLine, ...meetingDetailBodyLines(event)].join("\n"); } @@ -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"); } @@ -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"), diff --git a/src/weeklySummary.ts b/src/weeklySummary.ts index 77a3909..9c70881 100644 --- a/src/weeklySummary.ts +++ b/src/weeklySummary.ts @@ -1,6 +1,7 @@ import type { WebClient } from "@slack/web-api"; import { assembleWeeklySummaryMessage, + calendarSubscribeLink, formatWeeklySummaryLine, isWeeklySummaryDue, isWithinWeek, @@ -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, }; } @@ -61,8 +63,17 @@ 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, @@ -70,6 +81,7 @@ function snapshotToEventInfo( startsAt: new Date(item.snapshot_starts_at), endsAt: new Date(item.snapshot_ends_at), location: item.snapshot_location, + calendarLink, }; } @@ -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) { @@ -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); } @@ -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, @@ -213,7 +237,8 @@ async function reflectInformationalReplyChange( event: EventRow, kind: "changed" | "removed" ): Promise { - if (!getSetting("informational_calendar_id")) return; + const calendarId = getSetting("informational_calendar_id"); + if (!calendarId) return; const summary = getMostRecentWeeklySummary(); if (!summary) return; @@ -243,6 +268,7 @@ async function reflectInformationalReplyChange( weekEnd: range.end, entries, label: "Informational Calendar", + subscribeLink: calendarSubscribeLink(calendarId), }); await postInformationalReply( client, @@ -352,6 +378,9 @@ export async function postDueWeeklySummary(client: WebClient): Promise { weekStart: start, weekEnd: end, entries, + subscribeLink: calendarSubscribeLink( + getSetting("team_meeting_calendar_id") + ), }); const { channel, ts } = await postWeeklySummary(client, channelId, text); @@ -373,7 +402,8 @@ export async function postDueWeeklySummary(client: WebClient): Promise { eventCount: events.length, }); - if (getSetting("informational_calendar_id")) { + const informationalCalendarId = getSetting("informational_calendar_id"); + if (informationalCalendarId) { const infoEvents = listEventsStartingInRange( start.toISOString(), end.toISOString(), @@ -389,6 +419,7 @@ export async function postDueWeeklySummary(client: WebClient): Promise { weekEnd: end, entries: infoEntries, label: "Informational Calendar", + subscribeLink: calendarSubscribeLink(informationalCalendarId), }); await postInformationalReply(client, channel, ts, infoText) .then(({ channel: replyChannel, ts: replyTs }) => { diff --git a/test/checkin.test.ts b/test/checkin.test.ts index c09eb4f..f5b2f6e 100644 --- a/test/checkin.test.ts +++ b/test/checkin.test.ts @@ -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, + /^✅ — 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/); +}); diff --git a/test/weeklySummary.test.ts b/test/weeklySummary.test.ts index f835b56..c56f03d 100644 --- a/test/weeklySummary.test.ts +++ b/test/weeklySummary.test.ts @@ -3,6 +3,7 @@ import { test } from "node:test"; import { DEFAULT_WEEKLY_SUMMARY_TIMING, assembleWeeklySummaryMessage, + calendarSubscribeLink, formatWeeklySummaryLine, isWeeklySummaryDue, isWithinWeek, @@ -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 = { @@ -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 = { @@ -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", () => { @@ -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, + /^/ + ); +}); + +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, / { + 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, + /📅 $/ + ); +}); + +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, /📅/); +});