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
12 changes: 12 additions & 0 deletions migrations/0009_multiday_children.sql
Original file line number Diff line number Diff line change
@@ -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;
32 changes: 28 additions & 4 deletions src/db/repo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};
Expand All @@ -198,16 +200,18 @@ 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 {
const now = nowIso();
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,
Expand All @@ -222,6 +226,7 @@ export function insertEvent(event: NewEvent): number {
event.endsAt,
event.checkinAt,
event.reactionCutoffAt,
event.multidayParentId ?? null,
now,
now
);
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
Expand All @@ -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 ?`
)
Expand All @@ -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,
Expand All @@ -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`
)
Expand Down
136 changes: 136 additions & 0 deletions src/domain/calendar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
* (`<parent id>::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
Expand Down
8 changes: 8 additions & 0 deletions src/domain/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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:
Expand Down
Loading
Loading