/** * Quarters, and the one shape everything dated turns into. * * PIG's argument is that one ledger answers the question, so the calendar is a * PROJECTION over records that already carry dates — contracts, obligations, * deals, commitments, allocations, compliance artefacts — not a second store * that would immediately drift from them. The only rows that live in their own * table are the ones with no other home: a meeting, a QBR, a reminder. * * Kept in @pig/core because the browser and the API must agree on the event * shape and on where a quarter begins. * * Two decisions are load-bearing here and are pinned by tests. * * **A quarter is half-open, [from, to).** Every temporal column in PIG is * `timestamp with time zone`; there is not a single `date` column. So a * quarter is an interval of instants, and consecutive quarters must tile * without overlapping. An inclusive upper bound puts a contract expiring at * exactly midnight on 1 October into both Q3 and Q4, and a GTM lead adding up * two quarters then counts it twice. * * **A fiscal year is named for the calendar year it ENDS in.** This is the * dominant convention among the companies whose paper PIG holds — a fiscal * year beginning April 2026 and ending March 2027 is FY2027. The alternative * (naming for the starting year) is also in use, which is exactly why the * choice is stated here once rather than assumed at each call site. */ export type QuarterNumber = 1 | 2 | 3 | 4; /** A fiscal or calendar quarter label, e.g. `2026-Q3`. */ export type Quarter = `${number}-Q${QuarterNumber}`; /** Half-open interval of instants: `from` is included, `to` is not. */ export interface QuarterBounds { from: Date; to: Date; quarter: Quarter; } /** * Kinds of dated thing the projection can emit. * * Every one of these is derived from a record that already carries the date, * except `calendar_entry`, which is the only row type the calendar owns. */ export const CALENDAR_EVENT_KINDS = [ 'expected_close', 'contract_effective', 'contract_expiry', 'contract_executed', 'renewal_notice', 'obligation_due', 'capacity_window', 'allocation_window', 'hold_expiry', 'supply_available_from', 'authorization_expiry', 'artifact_expiry', 'calendar_entry', ] as const; export type CalendarEventKind = (typeof CALENDAR_EVENT_KINDS)[number]; export function isCalendarEventKind(value: string): value is CalendarEventKind { return (CALENDAR_EVENT_KINDS as readonly string[]).includes(value); } /** The kinds a human-owned `calendar_entries` row may take. */ export const CALENDAR_ENTRY_KINDS = [ 'meeting', 'qbr', 'reminder', 'campaign', 'internal', ] as const; export type CalendarEntryKind = (typeof CALENDAR_ENTRY_KINDS)[number]; /** * `done` is set by a completion column, never by the clock — an obligation * whose due date has passed is `overdue`, not finished, and conflating the two * is how a missed renewal notice disappears from a screen. */ export type CalendarEventState = 'upcoming' | 'due' | 'overdue' | 'done'; export interface CalendarEvent { /** * `${recordType}:${recordId}:${field}` — synthesised, never stored. A * derived projection has no primary key of its own, and inventing one in a * table would mean the same expiry date living in two places. */ id: string; kind: CalendarEventKind; title: string; /** * ISO-8601. Strings rather than `Date` because this interface crosses the * wire: the browser receives JSON, and a shape that only typechecks before * serialisation is a shape the front end cannot honestly claim to hold. */ startsAt: string; /** Null for a point in time. */ endsAt: string | null; isSpan: boolean; state: CalendarEventState; accountId: string | null; accountName: string | null; ownerUserId: string | null; /** Integer cents, per the money rule. Null where the event has no value. */ amountCents: number | null; currency: string | null; /** The table the event was derived from, e.g. `contract`. */ recordType: string; recordId: string; href: string; meta: Record; } /** Stable, storage-free identity for a projected event. */ export function calendarEventId( recordType: string, recordId: string, field: string, ): string { return `${recordType}:${recordId}:${field}`; } /** * How soon before its date an event counts as `due` rather than `upcoming`. * Seven days is the shortest horizon in which a renewal notice or an export * authorisation can still realistically be acted on. */ export const CALENDAR_DUE_HORIZON_DAYS = 7; const DAY_MS = 86_400_000; export function eventState(input: { at: Date; now: Date; completedAt?: Date | null; dueWithinDays?: number; }): CalendarEventState { if (input.completedAt) return 'done'; const at = input.at.getTime(); const now = input.now.getTime(); if (at < now) return 'overdue'; const horizon = (input.dueWithinDays ?? CALENDAR_DUE_HORIZON_DAYS) * DAY_MS; return at - now <= horizon ? 'due' : 'upcoming'; } /** * A span's state reads from its END, because a window that has started is not * late — it is running. Only a window that has closed is behind us. * * `done` is right here only because these spans have no completion column: a * capacity or allocation window IS the fact, and it is over when the clock says * so. A span nobody could fail to do cannot be overdue. For anything a person * was supposed to do inside the window, use `completableSpanState`. */ export function spanState(input: { startsAt: Date; endsAt: Date; now: Date; }): CalendarEventState { if (input.endsAt.getTime() < input.now.getTime()) return 'done'; if (input.startsAt.getTime() <= input.now.getTime()) return 'due'; return eventState({ at: input.startsAt, now: input.now }); } /** * The same reading of a span for a row that CAN record completion — today only * `calendar_entries`, which carries `completed_at`. * * The end still decides running versus past, but a closed window with nothing * in the completion column is `overdue`, not `done`. Reusing `spanState` here * made a missed QBR read as finished purely because its author had typed an end * time — the byte-identical entry without one read `overdue` — which is exactly * the conflation the note above `CalendarEventState` forbids. */ export function completableSpanState(input: { startsAt: Date; endsAt: Date; now: Date; completedAt?: Date | null; }): CalendarEventState { if (input.completedAt) return 'done'; if (input.endsAt.getTime() < input.now.getTime()) return 'overdue'; if (input.startsAt.getTime() <= input.now.getTime()) return 'due'; return eventState({ at: input.startsAt, now: input.now }); } // ---------------------------------------------------------------- time zones interface ZonedParts { year: number; month: number; day: number; hour: number; minute: number; second: number; } /** * Constructing an `Intl.DateTimeFormat` is expensive enough to be worth * keeping, but the key is a caller-supplied string that reaches here from a * query parameter, so the map is capped: an unbounded one lets a loop over * distinct values grow a long-lived API process without limit. The IANA * database has well under 500 zones, so a real deployment never evicts; * insertion-order eviction only ever bites junk. */ const FORMATTER_CACHE_LIMIT = 512; const formatterCache = new Map(); /** Whether the runtime's ICU data recognises `timeZone` as an IANA zone. */ export function isValidTimeZone(timeZone: string): boolean { try { new Intl.DateTimeFormat('en-US', { timeZone }); return true; } catch { return false; } } /** * Falls back to UTC rather than throwing. `users.timezone` is free text set * through a preferences endpoint that does not validate it, so a stale or * mistyped zone must degrade to a defensible answer instead of 500ing the * whole calendar. Request-supplied zones are rejected at the route boundary * instead, where a 400 can still tell the caller what was wrong. */ function formatterFor(timeZone: string): Intl.DateTimeFormat { const cached = formatterCache.get(timeZone); if (cached) return cached; let formatter: Intl.DateTimeFormat; try { formatter = new Intl.DateTimeFormat('en-US', { timeZone, hourCycle: 'h23', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', }); } catch { formatter = formatterFor('UTC'); } if (formatterCache.size >= FORMATTER_CACHE_LIMIT) { const oldest = formatterCache.keys().next(); if (!oldest.done) formatterCache.delete(oldest.value); } formatterCache.set(timeZone, formatter); return formatter; } function zonedParts(instant: Date, timeZone: string): ZonedParts { const parts = formatterFor(timeZone).formatToParts(instant); const read = (type: Intl.DateTimeFormatPartTypes): number => Number(parts.find((part) => part.type === type)?.value ?? '0'); return { year: read('year'), month: read('month'), day: read('day'), hour: read('hour'), minute: read('minute'), second: read('second'), }; } function offsetMsAt(instant: Date, timeZone: string): number { const parts = zonedParts(instant, timeZone); const asIfUtc = Date.UTC( parts.year, parts.month - 1, parts.day, parts.hour, parts.minute, parts.second, ); return asIfUtc - instant.getTime(); } /** * The instant at which local midnight begins on a given day in a given zone. * * Iterated rather than solved because the offset depends on the instant we are * trying to find. Two passes settle every real zone including the daylight * transitions; the third is insurance and costs nothing. */ function startOfZonedDay( year: number, month: number, day: number, timeZone: string, ): Date { const wallClock = Date.UTC(year, month - 1, day); let instant = wallClock; for (let pass = 0; pass < 3; pass += 1) { instant = wallClock - offsetMsAt(new Date(instant), timeZone); } return new Date(instant); } // ------------------------------------------------------------------ quarters function assertFiscalStart(fiscalYearStartMonth: number): void { if ( !Number.isInteger(fiscalYearStartMonth) || fiscalYearStartMonth < 0 || fiscalYearStartMonth > 11 ) { throw new RangeError( `fiscalYearStartMonth must be an integer month index 0–11, got ${fiscalYearStartMonth}.`, ); } } /** * Which quarter an instant falls in. * * `fiscalYearStartMonth` is a zero-based month index: 0 for calendar quarters * (the default), 3 for an April start, 9 for an October start. `timeZone` * decides the boundary, and it matters: 31 December 23:00 in New York is * already Q1 in London. */ export function quarterOf( date: Date, fiscalYearStartMonth = 0, timeZone = 'UTC', ): Quarter { assertFiscalStart(fiscalYearStartMonth); const local = zonedParts(date, timeZone); const monthIndex = local.month - 1; // Months elapsed since the fiscal year began, 0–11. const sinceStart = (monthIndex - fiscalYearStartMonth + 12) % 12; const quarter = (Math.floor(sinceStart / 3) + 1) as QuarterNumber; // Named for the year the fiscal year ends in — see the note at the top. const startedThisCalendarYear = monthIndex >= fiscalYearStartMonth; const fiscalYear = fiscalYearStartMonth === 0 ? local.year : startedThisCalendarYear ? local.year + 1 : local.year; return `${fiscalYear}-Q${quarter}`; } /** * The half-open bounds of a quarter, as instants. * * `year` is the fiscal year label, not the calendar year in which the quarter * starts — those differ for every non-calendar fiscal offset, which is the * mistake this signature exists to make hard to write. */ export function quarterBounds( year: number, quarter: QuarterNumber, fiscalYearStartMonth = 0, timeZone = 'UTC', ): QuarterBounds { assertFiscalStart(fiscalYearStartMonth); if (!Number.isInteger(quarter) || quarter < 1 || quarter > 4) { throw new RangeError(`quarter must be 1–4, got ${quarter}.`); } // Inverse of the labelling rule in quarterOf: a non-calendar fiscal year // labelled `year` began in the previous calendar year. const fiscalStartCalendarYear = fiscalYearStartMonth === 0 ? year : year - 1; const startMonthIndex = fiscalYearStartMonth + (quarter - 1) * 3; const from = startOfZonedDay( fiscalStartCalendarYear + Math.floor(startMonthIndex / 12), (startMonthIndex % 12) + 1, 1, timeZone, ); const endMonthIndex = startMonthIndex + 3; const to = startOfZonedDay( fiscalStartCalendarYear + Math.floor(endMonthIndex / 12), (endMonthIndex % 12) + 1, 1, timeZone, ); return { from, to, quarter: `${year}-Q${quarter}` }; } /** Splits `2026-Q3` back into its parts. Null when the label is malformed. */ export function parseQuarter( label: string, ): { year: number; quarter: QuarterNumber } | null { const match = /^(\d{4})-Q([1-4])$/.exec(label); if (!match) return null; return { year: Number(match[1]), quarter: Number(match[2]) as QuarterNumber, }; } /** The bounds of the quarter containing `date`. */ export function quarterBoundsFor( date: Date, fiscalYearStartMonth = 0, timeZone = 'UTC', ): QuarterBounds { const parsed = parseQuarter(quarterOf(date, fiscalYearStartMonth, timeZone)); if (!parsed) throw new Error('quarterOf produced an unparseable label.'); return quarterBounds(parsed.year, parsed.quarter, fiscalYearStartMonth, timeZone); }