From ca447ece504d93a644737e1499b502a7a7317231 Mon Sep 17 00:00:00 2001 From: Rachel Moore Date: Wed, 19 Aug 2026 11:12:33 -0400 Subject: [PATCH] Implement Multi-Day Event attendance via per-day Check-in Posts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Multi-Day Event now generates one synthetic Multi-Day Child Event per calendar day of its span (ordinary all_day Events), reusing the existing Check-in Post / Reaction Cutoff / Event Attendance Report machinery unmodified. Every Child's Check-in Post is front-loaded to a shared time (checkin_offset_multiday_days, default 2 days before the span's first day), while each Child's own Reaction Cutoff still lands independently at the end of its own day. The Multi-Day Event parent row is untouched — it keeps driving the Weekly Summary Post exactly as before. Child identity is keyed by calendar date (not by offset from the current span's start), so a span shift reconciles only the days that actually entered or left the span rather than reassigning every day's already- collected attendance. Reconciliation never touches an already-finalized Child, and a Multi-Day Event edited down to a non-multi-day span cleans up its previously-generated Children. Multi-Day Children are excluded from the No Response Alert's streak query (one missed competition isn't three missed meetings) but still count normally toward Season hours/attendance %. Closes #32. Co-Authored-By: Claude Sonnet 5 --- migrations/0009_multiday_children.sql | 12 ++ src/db/repo.ts | 32 +++- src/domain/calendar.ts | 136 ++++++++++++++ src/domain/settings.ts | 8 + src/scheduler.ts | 247 +++++++++++++++++++++++++- test/calendar.test.ts | 192 ++++++++++++++++++++ test/settings.test.ts | 7 + 7 files changed, 625 insertions(+), 9 deletions(-) create mode 100644 migrations/0009_multiday_children.sql diff --git a/migrations/0009_multiday_children.sql b/migrations/0009_multiday_children.sql new file mode 100644 index 0000000..53cbccb --- /dev/null +++ b/migrations/0009_multiday_children.sql @@ -0,0 +1,12 @@ +-- Multi-Day Child Events. A Multi-Day Event's attendance is tracked via +-- synthetic per-day Child Events (ordinary all_day rows), not by the +-- Multi-Day Event itself. This column links a Child back to its parent — +-- NULL for every ordinary Event, set only on a Child. Used both to exclude +-- Children from Weekly Summary listings (see listEventsStartingInRange) and +-- to find "every Child of this parent" when a competition's span changes on +-- a later sync (see reconcileMultiDayChildren). See CONTEXT.md, Multi-Day +-- Child Event. +ALTER TABLE events ADD COLUMN multiday_parent_id INTEGER REFERENCES events (id); + +CREATE INDEX events_multiday_parent ON events (multiday_parent_id) + WHERE multiday_parent_id IS NOT NULL; diff --git a/src/db/repo.ts b/src/db/repo.ts index 7def91d..d15305d 100644 --- a/src/db/repo.ts +++ b/src/db/repo.ts @@ -181,6 +181,8 @@ export type EventRow = { finalized_at: string | null; removed_at: string | null; verification_failed_at: string | null; + /** Set only on a Multi-Day Child Event, pointing back at its Multi-Day Event parent. See CONTEXT.md, Multi-Day Child Event. */ + multiday_parent_id: number | null; created_at: string; updated_at: string; }; @@ -198,6 +200,8 @@ export type NewEvent = { endsAt: string; checkinAt: string; reactionCutoffAt: string; + /** Unset (null) for every ordinary Event; set only when inserting a Multi-Day Child Event. */ + multidayParentId?: number | null; }; export function insertEvent(event: NewEvent): number { @@ -205,9 +209,9 @@ export function insertEvent(event: NewEvent): number { const result = db() .prepare( `INSERT INTO events (calendar_event_id, calendar_link, source, calendar_role, title, description, location, - meeting_type, starts_at, ends_at, checkin_at, reaction_cutoff_at, + meeting_type, starts_at, ends_at, checkin_at, reaction_cutoff_at, multiday_parent_id, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` ) .run( event.calendarEventId, @@ -222,6 +226,7 @@ export function insertEvent(event: NewEvent): number { event.endsAt, event.checkinAt, event.reactionCutoffAt, + event.multidayParentId ?? null, now, now ); @@ -257,6 +262,15 @@ export function updateEventFromCalendar( ); } +/** Every Multi-Day Child Event of one Multi-Day Event parent, including already-removed/finalized ones — see reconcileMultiDayChildren. */ +export function listMultiDayChildren(parentId: number): EventRow[] { + return db() + .prepare<[number], EventRow>( + "SELECT * FROM events WHERE multiday_parent_id = ?" + ) + .all(parentId); +} + export function getEventByCalendarId( calendarEventId: string ): EventRow | undefined { @@ -594,7 +608,11 @@ export type RecentOutcomeRow = { * belt-and-suspenders: every event_roster row is already team_meeting-only * in practice (see snapshotRoster/listEventsDueForCheckin), but nothing in * the schema enforces that. Feeds domain/noResponseAlert.ts's - * isNoResponseStreak. + * isNoResponseStreak — which is why Multi-Day Child Events are excluded + * here (unlike season/export hours totals, which do count each competition + * day): a "streak" is meant to read as a pattern across separate meetings, + * and counting one missed 3-day competition as three strikes would trip + * the same threshold a real three-week pattern does, on a single absence. */ export function getRecentOutcomesForUser( userId: string, @@ -608,6 +626,7 @@ export function getRecentOutcomesForUser( LEFT JOIN attendance a ON a.event_id = e.id AND a.user_id = r.user_id WHERE ${COUNTABLE_EVENT_WHERE} AND e.calendar_role = 'team_meeting' + AND e.multiday_parent_id IS NULL ORDER BY e.starts_at DESC LIMIT ?` ) @@ -619,7 +638,11 @@ export function getRecentOutcomesForUser( /** * Every non-removed Event starting in range for one Calendar Role, any * Meeting Type — for the Team Meeting Weekly Summary, the Informational - * reply, and the Mentor/Teacher Weekly Summary alike. + * reply, and the Mentor/Teacher Weekly Summary alike. Excludes Multi-Day + * Child Events: a Multi-Day Event's parent row already lists it once, with + * its full date range, so its per-day Children (ordinary all_day rows) + * would otherwise show up individually alongside it — see CONTEXT.md, + * Multi-Day Child Event. */ export function listEventsStartingInRange( startIso: string, @@ -630,6 +653,7 @@ export function listEventsStartingInRange( .prepare<[EventCalendarRole, string, string], EventRow>( `SELECT * FROM events WHERE removed_at IS NULL AND calendar_role = ? + AND multiday_parent_id IS NULL AND starts_at >= ? AND starts_at < ? ORDER BY starts_at` ) diff --git a/src/domain/calendar.ts b/src/domain/calendar.ts index 1770c8a..29c57f0 100644 --- a/src/domain/calendar.ts +++ b/src/domain/calendar.ts @@ -51,6 +51,14 @@ function parseDateOnly(dateStr: string): Date { return new Date(y ?? 1970, (m ?? 1) - 1, d ?? 1); } +/** The inverse of `parseDateOnly` — a local calendar date as "YYYY-MM-DD". */ +function formatDateOnly(date: Date): string { + const y = date.getFullYear(); + const m = String(date.getMonth() + 1).padStart(2, "0"); + const d = String(date.getDate()).padStart(2, "0"); + return `${y}-${m}-${d}`; +} + /** * Google represents an all-day event's end as the day *after* its last day, * even for a single-day event — a same-day event has `end.date` one day @@ -222,6 +230,134 @@ export function diffEvent( : { kind: "edited", changedFields }; } +/** + * A Multi-Day Event's synthetic per-day Child — an ordinary All-Day Event + * that gets its own Event Check-in Post, Reaction Cutoff, and Event + * Attendance Report, reusing that machinery unmodified (see CONTEXT.md, + * Multi-Day Child Event). `checkinAt` is identical across every Child of + * the same Multi-Day Event — front-loaded, N days before the span's first + * day — but each Child's own Reaction Cutoff (computed by the caller via + * the existing `reactionCutoff()`, keyed to that Child's own + * `startsAt`/`endsAt`) still lands independently at the end of its own day. + */ +export type MultiDayChildSpec = { + calendarEventId: string; + dayNumber: number; + title: string; + meetingType: "all_day"; + startsAt: Date; + endsAt: Date; + checkinAt: Date; +}; + +/** + * A Multi-Day Event longer than this many days is flagged for a HawkBot + * Admin to handle manually rather than generating Children for it — see + * CONTEXT.md, Multi-Day Child Event. Comfortably above any realistic FRC + * competition length. + */ +export const MULTI_DAY_MAX_DAYS = 10; + +export type MultiDayChildEventsResult = + { ok: true; children: MultiDayChildSpec[] } | { ok: false; dayCount: number }; + +/** + * One Multi-Day Child per calendar day of the span, `Day N of M` baked into + * each title at generation time. `endsAt` follows the same Google + * exclusive-end convention as every other All-Day Event (see MappedEvent). + * + * Each Child's `calendarEventId` is keyed by its actual calendar date + * (`::YYYY-MM-DD`), not by its offset from the start of the + * current span — a span shift (the whole event moving a day earlier or + * later, not just growing or shrinking) still identifies "the day that is + * Mar 6" as the same Child it always was, so `reconcileMultiDayChildren` + * only ever creates/removes the days that actually entered or left the + * span, and never reattaches one day's already-collected attendance to a + * different date. + */ +export function multiDayChildEvents( + parent: { + calendarEventId: string; + title: string; + startsAt: Date; + endsAt: Date; + }, + offsets: CheckinPostOffsets, + multiDayDaysBefore: number +): MultiDayChildEventsResult { + const dayCount = Math.round( + (parent.endsAt.getTime() - parent.startsAt.getTime()) / DAY_MS + ); + if (dayCount > MULTI_DAY_MAX_DAYS) return { ok: false, dayCount }; + + const checkinAt = new Date( + parent.startsAt.getFullYear(), + parent.startsAt.getMonth(), + parent.startsAt.getDate() - multiDayDaysBefore, + offsets.allDayHour, + offsets.allDayMinute + ); + + const children: MultiDayChildSpec[] = []; + for (let i = 0; i < dayCount; i++) { + const dayNumber = i + 1; + const startsAt = new Date( + parent.startsAt.getFullYear(), + parent.startsAt.getMonth(), + parent.startsAt.getDate() + i + ); + children.push({ + calendarEventId: `${parent.calendarEventId}::${formatDateOnly(startsAt)}`, + dayNumber, + title: `${parent.title} (Day ${dayNumber} of ${dayCount})`, + meetingType: "all_day", + startsAt, + endsAt: midnightEnding(startsAt), + checkinAt, + }); + } + return { ok: true, children }; +} + +/** The "(Day N of M)" suffix `multiDayChildEvents` bakes into a Child's title. */ +const DAY_SUFFIX_RE = / \(Day \d+ of \d+\)$/; + +/** + * Strips a Child's Day-N-of-M suffix, so its title can be compared for a + * real edit without a shift in M — the total day count, which changes + * whenever any day (not necessarily this one) is added to or dropped from + * the span — being mistaken for an edit to this day's own title. + */ +export function withoutDaySuffix(title: string): string { + return title.replace(DAY_SUFFIX_RE, ""); +} + +/** Starting default, used until a Hawk Bot admin sets `checkin_offset_multiday_days`. */ +export const DEFAULT_MULTIDAY_DAYS_BEFORE = 2; + +export function resolveMultiDayDaysBefore(setting: string | undefined): number { + return setting ? Number(setting) : DEFAULT_MULTIDAY_DAYS_BEFORE; +} + +/** + * Diffs a Multi-Day Event's currently-stored Child ids against the set its + * (possibly just-edited) span now calls for — which Children to create for + * a newly added day, and which to mark removed for a dropped one. Pure: the + * caller decides what removal actually means for an already-finalized + * Child (never retroactively altered) versus one that hasn't posted yet. + */ +export function reconcileMultiDayChildren( + existingChildCalendarEventIds: readonly string[], + desiredChildCalendarEventIds: readonly string[] +): { toCreate: string[]; toRemove: string[] } { + const existingSet = new Set(existingChildCalendarEventIds); + const desiredSet = new Set(desiredChildCalendarEventIds); + return { + toCreate: desiredChildCalendarEventIds.filter((id) => !existingSet.has(id)), + toRemove: existingChildCalendarEventIds.filter((id) => !desiredSet.has(id)), + }; +} + /** * The next `count` non-cancelled events starting at or after `now`, earliest * first — what "upcoming" means for a calendar preview. Shared by any diff --git a/src/domain/settings.ts b/src/domain/settings.ts index 16d27f4..eb985e2 100644 --- a/src/domain/settings.ts +++ b/src/domain/settings.ts @@ -17,6 +17,7 @@ export type SettingKey = | "google_impersonated_user" | "checkin_offset_hourly_hours" | "checkin_offset_allday_time" + | "checkin_offset_multiday_days" | "default_all_day_hours" | "attendance_report_channel" | "admin_usergroup" @@ -131,6 +132,13 @@ export const SETTINGS: readonly Setting[] = [ expects: "a 24-hour time HH:MM, e.g. 16:00", validate: (v) => /^([01]\d|2[0-3]):([0-5]\d)$/.test(v.trim()), }, + { + key: "checkin_offset_multiday_days", + summary: + "How many days before a Multi-Day Event's first day every one of its per-day Check-in Posts goes out together", + expects: "a whole number of days, e.g. 2", + validate: (v) => /^\d+$/.test(v.trim()) && Number(v.trim()) > 0, + }, { key: "default_all_day_hours", summary: diff --git a/src/scheduler.ts b/src/scheduler.ts index 5456bbf..ab7a52e 100644 --- a/src/scheduler.ts +++ b/src/scheduler.ts @@ -16,8 +16,13 @@ import { checkinPostTime, diffEvent, mapCalendarEvent, + MULTI_DAY_MAX_DAYS, + multiDayChildEvents, reactionCutoff, + reconcileMultiDayChildren, resolveCheckinOffsets, + resolveMultiDayDaysBefore, + withoutDaySuffix, type CheckinPostOffsets, type MappedEvent, } from "./domain/calendar.js"; @@ -32,6 +37,7 @@ import { insertEvent, listEventsDueForCheckin, listEventsDueForCutoff, + listMultiDayChildren, markCheckinPosted, markEventFinalized, markEventRemoved, @@ -87,6 +93,10 @@ function defaultAllDayHours(): number { return resolveAllDayHours(getSetting("default_all_day_hours")); } +function multiDayDaysBefore(): number { + return resolveMultiDayDaysBefore(getSetting("checkin_offset_multiday_days")); +} + function toMappedEvent(row: EventRow): MappedEvent { return { calendarEventId: row.calendar_event_id ?? "", @@ -101,6 +111,213 @@ function toMappedEvent(row: EventRow): MappedEvent { }; } +/** + * A Multi-Day Child's MappedEvent, for reuse with `diffEvent` — same shape + * `toMappedEvent` produces for a stored row, but built from a freshly + * generated MultiDayChildSpec plus the parent's current + * description/location/link (a Child has no calendar entry of its own to + * read those from; it inherits them unchanged from its Multi-Day Event + * parent every sync). + */ +function toChildMappedEvent( + spec: { + calendarEventId: string; + title: string; + startsAt: Date; + endsAt: Date; + }, + parentMapped: MappedEvent +): MappedEvent { + return { + calendarEventId: spec.calendarEventId, + title: spec.title, + description: parentMapped.description, + location: parentMapped.location, + meetingType: "all_day", + startsAt: spec.startsAt, + endsAt: spec.endsAt, + cancelled: false, + calendarLink: parentMapped.calendarLink, + }; +} + +/** + * DMs every HawkBot Admin that a Multi-Day Event was too long to + * auto-generate Children for — see domain/calendar.ts, MULTI_DAY_MAX_DAYS. + * Mirrors notifyVerificationFailure's DM-every-admin shape. + */ +async function notifyMultiDayOverCap( + client: WebClient, + parentMapped: MappedEvent, + dayCount: number +): Promise { + const text = [ + `⚠️ *${parentMapped.title}* spans ${dayCount} days, over the ${MULTI_DAY_MAX_DAYS}-day limit for automatic Multi-Day Check-in Posts.`, + "No Check-in Posts were generated for it — this one needs to be handled manually.", + ].join("\n"); + + for (const userId of await listHawkBotAdmins(client)) { + const dmChannel = await openDirectMessage(client, userId); + if (!dmChannel) continue; + await client.chat.postMessage({ channel: dmChannel, text }).catch((err) => + log.error("could not DM multi-day over-cap notice", { + userId, + calendarEventId: parentMapped.calendarEventId, + error: String(err), + }) + ); + } +} + +/** + * `markEventRemoved` plus the "announce it, but only if it was already + * live" step every Removed path needs — the pre-existing single-Event + * removed branch and both new Multi-Day Child removal call sites all do + * exactly this, so it lives once here instead of three times. Refuses to + * touch a row that's already removed or already finalized: a finalized + * Event's recorded attendance is never retroactively altered by sync, + * Multi-Day Children included (see `syncOneCalendar`'s own + * `existing.finalized_at || existing.removed_at` guard). + */ +async function removeEventAndAnnounce( + client: WebClient, + row: EventRow +): Promise { + if (row.removed_at || row.finalized_at) return; + markEventRemoved(row.id); + if (row.checkin_posted_at) { + await announceEventRemoved(client, row); + } +} + +/** + * Marks every still-live, not-yet-finalized Multi-Day Child of a cancelled + * Multi-Day Event removed, announcing the cancellation on any that already + * had a live Check-in Post — the same single-Event Removed handling every + * other Event gets, just run once per Child. See CONTEXT.md, Multi-Day + * Child Event. + */ +async function removeAllMultiDayChildren( + client: WebClient, + parentId: number +): Promise { + for (const child of listMultiDayChildren(parentId)) { + await removeEventAndAnnounce(client, child); + } +} + +function stripDaySuffix(mapped: MappedEvent): MappedEvent { + return { ...mapped, title: withoutDaySuffix(mapped.title) }; +} + +/** + * Generates/reconciles a Multi-Day Event's per-day Children against its + * current span — creating a Child for a newly added day, marking removed + * one for a dropped day (never touching an already-finalized Child's + * recorded attendance), and propagating a parent-level field edit (title, + * description, location) to every still-live Child, reusing the exact + * single-Event Calendar Change Handling flow once per Child. Every other + * mechanic (Check-in Post, Reaction Cutoff, Event Attendance Report) needs + * no special handling here at all — a Child is an ordinary all_day Event + * once it exists, and `postDueCheckins`/`finalizeDueCutoffs` pick it up the + * same way they would any other. See CONTEXT.md, Multi-Day Child Event. + */ +async function syncMultiDayChildren( + client: WebClient, + parentRow: EventRow, + parentMapped: MappedEvent, + offsets: CheckinPostOffsets +): Promise { + const result = multiDayChildEvents( + parentMapped, + offsets, + multiDayDaysBefore() + ); + if (!result.ok) { + await notifyMultiDayOverCap(client, parentMapped, result.dayCount); + return; + } + + const existingChildren = listMultiDayChildren(parentRow.id); + const { toCreate, toRemove } = reconcileMultiDayChildren( + existingChildren.map((c) => c.calendar_event_id ?? ""), + result.children.map((c) => c.calendarEventId) + ); + + for (const spec of result.children) { + if (!toCreate.includes(spec.calendarEventId)) continue; + insertEvent({ + calendarEventId: spec.calendarEventId, + calendarLink: parentMapped.calendarLink, + source: "google_calendar", + calendarRole: parentRow.calendar_role, + title: spec.title, + description: parentMapped.description, + location: parentMapped.location, + meetingType: spec.meetingType, + startsAt: spec.startsAt.toISOString(), + endsAt: spec.endsAt.toISOString(), + checkinAt: spec.checkinAt.toISOString(), + reactionCutoffAt: reactionCutoff({ + meetingType: spec.meetingType, + startsAt: spec.startsAt, + endsAt: spec.endsAt, + }).toISOString(), + multidayParentId: parentRow.id, + }); + } + + for (const child of existingChildren) { + if (!toRemove.includes(child.calendar_event_id ?? "")) continue; + await removeEventAndAnnounce(client, child); + } + + const continuing = existingChildren.filter( + (c) => + !c.removed_at && + !c.finalized_at && + !toRemove.includes(c.calendar_event_id ?? "") + ); + for (const child of continuing) { + const spec = result.children.find( + (s) => s.calendarEventId === child.calendar_event_id + ); + if (!spec) continue; + + const current = toChildMappedEvent(spec, parentMapped); + const change = diffEvent( + stripDaySuffix(toMappedEvent(child)), + stripDaySuffix(current) + ); + if (change.kind !== "edited") continue; + + updateEventFromCalendar(child.id, { + calendarLink: current.calendarLink, + title: current.title, + description: current.description, + location: current.location, + meetingType: current.meetingType, + startsAt: current.startsAt.toISOString(), + endsAt: current.endsAt.toISOString(), + checkinAt: spec.checkinAt.toISOString(), + reactionCutoffAt: reactionCutoff({ + meetingType: current.meetingType, + startsAt: current.startsAt, + endsAt: current.endsAt, + }).toISOString(), + }); + const updatedChild = getEvent(child.id); + if (updatedChild && child.checkin_posted_at) { + await announceEventEdited( + client, + child, + updatedChild, + change.changedFields + ); + } + } +} + /** * The team's three Google Calendars, each read through the same shared * service account, and the Calendar Role their Events are tagged with once @@ -191,7 +408,15 @@ async function syncOneCalendar( reactionCutoffAt: reactionCutoff(mapped).toISOString(), }); const newRow = getEvent(newId); - if (newRow) await reflectWeeklySummaryChange(client, newRow, "changed"); + if (newRow) { + await reflectWeeklySummaryChange(client, newRow, "changed"); + if ( + mapped.meetingType === "multi_day" && + calendarRole === "team_meeting" + ) { + await syncMultiDayChildren(client, newRow, mapped, offsets); + } + } continue; } @@ -201,11 +426,11 @@ async function syncOneCalendar( if (change.kind === "unchanged") continue; if (change.kind === "removed") { - markEventRemoved(existing.id); - if (existing.checkin_posted_at) { - await announceEventRemoved(client, existing); - } + await removeEventAndAnnounce(client, existing); await reflectWeeklySummaryChange(client, existing, "removed"); + if (existing.meeting_type === "multi_day") { + await removeAllMultiDayChildren(client, existing.id); + } continue; } @@ -234,6 +459,18 @@ async function syncOneCalendar( ); } await reflectWeeklySummaryChange(client, updated, "changed"); + if ( + updated.meeting_type === "multi_day" && + updated.calendar_role === "team_meeting" + ) { + await syncMultiDayChildren(client, updated, mapped, offsets); + } else if (existing.meeting_type === "multi_day") { + // The span no longer maps to Multi-Day (e.g. shortened to a single + // day) — any Children generated while it still was one are no + // longer reconciled by anything else, so clean them up here rather + // than leaving them orphaned. + await removeAllMultiDayChildren(client, existing.id); + } } } } diff --git a/test/calendar.test.ts b/test/calendar.test.ts index cf0e574..aa1401e 100644 --- a/test/calendar.test.ts +++ b/test/calendar.test.ts @@ -4,9 +4,14 @@ import { checkinPostTime, diffEvent, mapCalendarEvent, + MULTI_DAY_MAX_DAYS, + multiDayChildEvents, reactionCutoff, + reconcileMultiDayChildren, resolveCheckinOffsets, + resolveMultiDayDaysBefore, upcomingEvents, + withoutDaySuffix, } from "../src/domain/calendar.js"; // Midnight/day-before math reads wall-clock dates via local Date methods, @@ -201,3 +206,190 @@ test("upcomingEvents drops past and cancelled events, earliest first", () => { test("upcomingEvents caps at count", () => { assert.equal(upcomingEvents([soon, later], now, 1).length, 1); }); + +/* ------------------------------------------------- Multi-Day Child Events */ + +const multiDayParent = mapCalendarEvent(multiDayRaw); // evt3, Mar 5 (incl.) – Mar 8 (excl.) = 3 days +const checkinOffsets = { + hourlyHoursBefore: 4, + allDayHour: 16, + allDayMinute: 0, +}; + +test("a 3-day Multi-Day Event generates one Child per day, labeled Day N of M", () => { + const result = multiDayChildEvents(multiDayParent, checkinOffsets, 2); + assert.equal(result.ok, true); + if (!result.ok) return; + assert.equal(result.children.length, 3); + assert.deepEqual( + result.children.map((c) => c.calendarEventId), + ["evt3::2026-03-05", "evt3::2026-03-06", "evt3::2026-03-07"] + ); + assert.deepEqual( + result.children.map((c) => c.title), + [ + "Regional Competition (Day 1 of 3)", + "Regional Competition (Day 2 of 3)", + "Regional Competition (Day 3 of 3)", + ] + ); + assert.ok(result.children.every((c) => c.meetingType === "all_day")); +}); + +test("each Multi-Day Child spans exactly its own calendar day, exclusive end", () => { + const result = multiDayChildEvents(multiDayParent, checkinOffsets, 2); + assert.equal(result.ok, true); + if (!result.ok) return; + const [day1, day2, day3] = result.children; + assert.ok(day1 && day2 && day3); + assert.equal(day1.startsAt.toISOString(), "2026-03-05T00:00:00.000Z"); + assert.equal(day1.endsAt.toISOString(), "2026-03-06T00:00:00.000Z"); + assert.equal(day2.startsAt.toISOString(), "2026-03-06T00:00:00.000Z"); + assert.equal(day2.endsAt.toISOString(), "2026-03-07T00:00:00.000Z"); + assert.equal(day3.startsAt.toISOString(), "2026-03-07T00:00:00.000Z"); + assert.equal(day3.endsAt.toISOString(), "2026-03-08T00:00:00.000Z"); +}); + +test("every Multi-Day Child shares the same front-loaded Check-in time, N days before the first day", () => { + const result = multiDayChildEvents(multiDayParent, checkinOffsets, 2); + assert.equal(result.ok, true); + if (!result.ok) return; + const checkinTimes = result.children.map((c) => c.checkinAt.toISOString()); + assert.deepEqual(checkinTimes, [ + "2026-03-03T16:00:00.000Z", + "2026-03-03T16:00:00.000Z", + "2026-03-03T16:00:00.000Z", + ]); +}); + +test("a Multi-Day Child, run through the existing All-Day Reaction Cutoff formula, gets an independent per-day cutoff", () => { + const result = multiDayChildEvents(multiDayParent, checkinOffsets, 2); + assert.equal(result.ok, true); + if (!result.ok) return; + const cutoffs = result.children.map((c) => + reactionCutoff({ + meetingType: c.meetingType, + startsAt: c.startsAt, + endsAt: c.endsAt, + }).toISOString() + ); + // Distinct, one per day, despite every Child sharing one checkinAt above. + assert.deepEqual(cutoffs, [ + "2026-03-06T00:00:00.000Z", + "2026-03-07T00:00:00.000Z", + "2026-03-08T00:00:00.000Z", + ]); +}); + +test(`a span at the ${MULTI_DAY_MAX_DAYS}-day cap still generates Children`, () => { + const tenDayEvent = mapCalendarEvent({ + ...multiDayRaw, + start: { date: "2026-03-05" }, + end: { date: "2026-03-15" }, // exclusive end, 10 calendar days + }); + const result = multiDayChildEvents(tenDayEvent, checkinOffsets, 2); + assert.equal(result.ok, true); + if (!result.ok) return; + assert.equal(result.children.length, MULTI_DAY_MAX_DAYS); +}); + +test(`a span one day over the ${MULTI_DAY_MAX_DAYS}-day cap is rejected, not truncated`, () => { + const elevenDayEvent = mapCalendarEvent({ + ...multiDayRaw, + start: { date: "2026-03-05" }, + end: { date: "2026-03-16" }, // exclusive end, 11 calendar days + }); + const result = multiDayChildEvents(elevenDayEvent, checkinOffsets, 2); + assert.deepEqual(result, { ok: false, dayCount: 11 }); +}); + +test("reconcileMultiDayChildren creates a Child for a day added to the span", () => { + assert.deepEqual( + reconcileMultiDayChildren( + ["evt3::day1", "evt3::day2", "evt3::day3"], + ["evt3::day1", "evt3::day2", "evt3::day3", "evt3::day4"] + ), + { toCreate: ["evt3::day4"], toRemove: [] } + ); +}); + +test("reconcileMultiDayChildren removes a Child for a day dropped from the middle of the span", () => { + assert.deepEqual( + reconcileMultiDayChildren( + ["evt3::day1", "evt3::day2", "evt3::day3"], + ["evt3::day1", "evt3::day3"] + ), + { toCreate: [], toRemove: ["evt3::day2"] } + ); +}); + +test("reconcileMultiDayChildren removes a Child for a day dropped off the end of the span", () => { + assert.deepEqual( + reconcileMultiDayChildren( + ["evt3::day1", "evt3::day2", "evt3::day3"], + ["evt3::day1", "evt3::day2"] + ), + { toCreate: [], toRemove: ["evt3::day3"] } + ); +}); + +test("reconcileMultiDayChildren is a no-op when the span hasn't changed", () => { + const ids = ["evt3::day1", "evt3::day2", "evt3::day3"]; + assert.deepEqual(reconcileMultiDayChildren(ids, ids), { + toCreate: [], + toRemove: [], + }); +}); + +test("a Multi-Day Event whose span shifts (not just grows or shrinks) keeps the same id for a day that stays in the span", () => { + // Mar 5-7 (3 days) shifts to Mar 6-8 (still 3 days, one day later). + const before = multiDayChildEvents(multiDayParent, checkinOffsets, 2); + const shifted = mapCalendarEvent({ + ...multiDayRaw, + start: { date: "2026-03-06" }, + end: { date: "2026-03-09" }, + }); + const after = multiDayChildEvents(shifted, checkinOffsets, 2); + assert.equal(before.ok, true); + assert.equal(after.ok, true); + if (!before.ok || !after.ok) return; + + const { toCreate, toRemove } = reconcileMultiDayChildren( + before.children.map((c) => c.calendarEventId), + after.children.map((c) => c.calendarEventId) + ); + // Mar 6 and Mar 7 are in both spans and must not be recreated; only + // Mar 5 (dropped) and Mar 8 (added) should move. + assert.deepEqual(toCreate, ["evt3::2026-03-08"]); + assert.deepEqual(toRemove, ["evt3::2026-03-05"]); +}); + +test("withoutDaySuffix strips the Day-N-of-M tag but leaves the rest of the title alone", () => { + assert.equal( + withoutDaySuffix("Regional Competition (Day 2 of 3)"), + "Regional Competition" + ); + assert.equal( + withoutDaySuffix("Regional Competition (Day 2 of 4)"), + "Regional Competition", + "a day count that only changed because another day was added/removed still strips cleanly" + ); + assert.equal( + withoutDaySuffix("State Championship (Day 2 of 3)"), + "State Championship", + "a real title edit alongside the Child's own suffix still leaves a comparably different base title" + ); + assert.equal( + withoutDaySuffix("Team Meeting"), + "Team Meeting", + "a title with no suffix at all is returned unchanged" + ); +}); + +test("an unset Multi-Day Check-in lead time falls back to the starting default", () => { + assert.equal(resolveMultiDayDaysBefore(undefined), 2); +}); + +test("a set Multi-Day Check-in lead time overrides the default", () => { + assert.equal(resolveMultiDayDaysBefore("3"), 3); +}); diff --git a/test/settings.test.ts b/test/settings.test.ts index 93faf34..d124292 100644 --- a/test/settings.test.ts +++ b/test/settings.test.ts @@ -94,6 +94,13 @@ test("the Hourly check-in offset is a positive whole number of hours", () => { assert.equal(checkSetting("checkin_offset_hourly_hours", "soon").ok, false); }); +test("the Multi-Day check-in lead time is a positive whole number of days", () => { + assert.equal(checkSetting("checkin_offset_multiday_days", "2").ok, true); + assert.equal(checkSetting("checkin_offset_multiday_days", "0").ok, false); + assert.equal(checkSetting("checkin_offset_multiday_days", "2.5").ok, false); + assert.equal(checkSetting("checkin_offset_multiday_days", "soon").ok, false); +}); + test("the All-Day check-in time is a 24-hour HH:MM", () => { assert.equal(checkSetting("checkin_offset_allday_time", "16:00").ok, true); assert.equal(checkSetting("checkin_offset_allday_time", "4:00 PM").ok, false);