Files
pig/packages/core/src/calendar.ts
T
karti 13dec6b4b8
CI / verify (push) Successful in 3m45s
CI / publish (push) Has been skipped
Rebuild the shell, add Calendar and Learn, and govern reads
Seven parallel agents and an adversarial verification pass. The three things
worth knowing before reading the diff:

RBAC WAS ALREADY BUILT. docs/build-plan.md marks F2 and F3 outstanding and is
stale — packages/core/src/permissions.ts and lib/mutation.ts shipped long ago.
So this does not rebuild them; it closes the gaps an audit found. The big one
is that reads were entirely ungoverned: every GET was "any authenticated
member", so a junior demand rep and a research contractor could both pull
per-block supplier cost and break-even prices from /api/capacity/margin, and
every contract's negotiated terms. For a company whose margin is the business,
that was the hole that mattered. Adds book:read / economics:read / team:read,
a readGuard middleware, and a `viewer` role below member.

THE BUTTON AND THE 403 DISAGREED — the exact thing F3 said must never happen.
Contracts.tsx never called can() at all, so its save button was always enabled
against a server requiring contract:sign; Capacity.tsx gated commitment
creation on deal:write/demand while the server wanted commitment:write/supply.

POST /api/activities was the one write bypassing executeMutation: no capability
check, and any member could mutate accounts.lastActivityAt as a side effect.
It is now a proper mutation() behind activity:write.

The shell becomes three panes — a collapsible shadcn sidebar with an account
switcher on the Piggy accent, a header with real search, and Piggy docked to
the right, page-aware and persistent across navigation. The phone keeps its
bottom tab bar, which is the thing this product already beat trycompai/crm on,
and gains the sidebar as a sheet.

Calendar is a projection over thirteen dated sources rather than a new table,
because a table would duplicate dates that already live on contracts, deals and
commitments and would drift — and one ledger answering the question is the
whole argument. It surfaces export_authorizations and compliance_artifacts,
which had indexed expires_at columns, schema comments saying they must be
alerted on, and no read endpoint or UI anywhere.

Learn carries two tracks. Concepts are members-only; the platform track can be
opened with a share code by someone with no account. The code mints a scoped
learn-only token and never a Principal — every route here resolves a principal
and then checks capabilities, so a principal-minting code would be one missing
check away from leaking the book. "Only platform-track rows may be code-visible"
is a database CHECK constraint as well as a write-path rule, and a test asserts
a valid learn token still gets 401 on /api/dashboard, /api/accounts and
/api/contracts — the same invariant scripts/deploy.sh refuses to ship without.

CD becomes tag-to-ship. CI publishes an image to the Gitea registry on a
release-* tag and cloud-2 pulls it, so no credential on the shared runner can
execute anything on production — by construction rather than by policy. Both
halves of deploy.sh's original rule survive: nothing on the runner reaches the
host, and a human still decides when it ships. deploy.sh gains a rollback and a
public-origin check, and PIG_IMAGE now reaches compose through `sudo env`,
without which sudo's env_reset silently resolved every release to pig:local.

Tests 141 -> 261.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 15:02:48 -07:00

409 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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<string, unknown>;
}
/** 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<string, Intl.DateTimeFormat>();
/** 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 011, 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, 011.
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 14, 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);
}