@cyc-seattle/platform 1.52.0 → 1.54.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -4,6 +4,9 @@ Pulumi stacks for CYC Community Sailing Center's shared Google Cloud platform: t
4
4
  app owns. The apps that run on it live in their own repos, such as
5
5
  [isthmia](https://github.com/cyc-seattle/isthmia).
6
6
 
7
+ The [docs site](https://cyc-seattle.github.io/platform/) publishes [`docs/`](docs) from `main`: how the
8
+ platform is built, its data model, and the planning engine.
9
+
7
10
  ## Stacks
8
11
 
9
12
  | Stack | Holds | Applied by |
@@ -164,9 +167,17 @@ login only when it isn't already set, so Actions' credentials win.
164
167
  mise run ci
165
168
  ```
166
169
 
167
- Runs the type check, Prettier, ESLint, and the unit tests. Tests mock Pulumi with
170
+ Runs the type check, Prettier, ESLint, the unit tests, and the docs site's build. Tests mock Pulumi with
168
171
  `pulumi.runtime.setMocks` and never call a live API.
169
172
 
173
+ ```sh
174
+ mise run docs-dev
175
+ ```
176
+
177
+ Serves the docs site, from `docs-site/`, as you edit `docs/`. A page in `docs/` takes its title from
178
+ its first heading, and a link to another file in the repo goes to GitHub. The build fails on a broken
179
+ link. `.github/workflows/docs.yml` publishes it to GitHub Pages on every push to `main`.
180
+
170
181
  ```sh
171
182
  mise run integration
172
183
  ```
@@ -35,8 +35,10 @@ export declare class Context {
35
35
  readonly planOfVersion: Map<Id, Plan>;
36
36
  readonly offeringsById: Map<Id, PlanOffering>;
37
37
  readonly offeringsByKey: Map<Id, PlanOffering>;
38
- /** Enrollment per offering id. */
38
+ /** Expected enrollment per offering id, which the costs and revenue use. */
39
39
  readonly enrollment: Map<string, number>;
40
+ /** The most sailors each offering id takes, which the checks use. */
41
+ readonly maxEnrollment: Map<string, number>;
40
42
  /** Days per offering key: the dates it has an activity other than travel. */
41
43
  readonly days: Map<string, number>;
42
44
  readonly rates: PlanRates | undefined;
@@ -28,8 +28,10 @@ export class Context {
28
28
  planOfVersion;
29
29
  offeringsById;
30
30
  offeringsByKey;
31
- /** Enrollment per offering id. */
31
+ /** Expected enrollment per offering id, which the costs and revenue use. */
32
32
  enrollment = new Map();
33
+ /** The most sailors each offering id takes, which the checks use. */
34
+ maxEnrollment = new Map();
33
35
  /** Days per offering key: the dates it has an activity other than travel. */
34
36
  days = new Map();
35
37
  rates;
@@ -9,18 +9,77 @@ import { allocatePools } from "./pools.js";
9
9
  import { bookRevenue, countDays } from "./revenue.js";
10
10
  import { bookSalaries } from "./salaried.js";
11
11
  import { compareTerms, offeringTerms } from "./terms.js";
12
- import { enrollmentOf, round2, Staffing } from "./staffing.js";
12
+ import { enrollmentOf, maxEnrollmentOf, round2, Staffing, } from "./staffing.js";
13
13
  /**
14
14
  * Calculates the plan year's P&L from a plan's rows. The steps run in the order
15
15
  * docs/planning-engine.md gives, each reading only what earlier steps recorded.
16
16
  */
17
17
  export function computePlan(data) {
18
+ const output = calculate(data, new Map());
19
+ for (const row of output.pnl) {
20
+ if (row.level !== "offering")
21
+ continue;
22
+ const offering = data.plan_offerings.find((o) => o.key === row.key);
23
+ if (offering !== undefined)
24
+ row.breakeven_enrollment = breakeven(data, offering, row.net_cents);
25
+ }
26
+ return output;
27
+ }
28
+ /**
29
+ * The fewest sailors, from 1 up to the offering's capacity, at which its net after shared costs is
30
+ * zero or more. Each number is tried in turn, since coaches and boats cost in steps. Recalculating the
31
+ * whole plan for each would take seconds, so only the offering's own slice is, and whatever lies
32
+ * outside it, such as its share of shared costs, stays as planned.
33
+ */
34
+ function breakeven(data, offering, planned) {
35
+ if (offering.capacity == null)
36
+ return null;
37
+ const slice = sliceOf(data, offering);
38
+ const netAt = (n) => calculate(slice, new Map([[offering.id, n]])).pnl.find((p) => p.level === "offering" && p.key === offering.key)
39
+ ?.net_cents ?? 0;
40
+ const outside = planned - netAt(enrollmentOf(offering));
41
+ for (let n = 1; n <= offering.capacity; n++)
42
+ if (netAt(n) + outside >= 0)
43
+ return n;
44
+ return null;
45
+ }
46
+ /**
47
+ * The rows that change with an offering's sailors: it, its activities, trips and lines, and its
48
+ * blocks with the activities that share them. Pools, bonuses and bookings are left out.
49
+ */
50
+ function sliceOf(data, offering) {
51
+ const own = data.plan_activities.filter((a) => a.offering_id === offering.id);
52
+ const blockIds = new Set(own.flatMap((a) => (a.block_id == null ? [] : [a.block_id])));
53
+ const activities = data.plan_activities.filter((a) => a.offering_id === offering.id || (a.block_id != null && blockIds.has(a.block_id)));
54
+ const offeringIds = new Set(activities.map((a) => a.offering_id).concat(offering.id));
55
+ const inBlocks = (rows) => rows.filter((r) => blockIds.has(r.block_id));
56
+ const onOffering = (rows) => rows.filter((r) => r.cost_center_type === "offering" && r.cost_center_key === offering.key);
57
+ return {
58
+ ...data,
59
+ plan_offerings: data.plan_offerings.filter((o) => offeringIds.has(o.id)),
60
+ plan_activities: activities,
61
+ plan_trips: data.plan_trips.filter((t) => t.offering_id === offering.id),
62
+ plan_activity_blocks: data.plan_activity_blocks.filter((b) => blockIds.has(b.id)),
63
+ plan_block_staff: inBlocks(data.plan_block_staff),
64
+ plan_block_boats: inBlocks(data.plan_block_boats),
65
+ plan_labor_lines: onOffering(data.plan_labor_lines),
66
+ plan_budget_lines: onOffering(data.plan_budget_lines),
67
+ plan_salaried_bookings: [],
68
+ plan_pools: [],
69
+ plan_bonus_owners: [],
70
+ plan_bonus_owner_products: [],
71
+ };
72
+ }
73
+ /** The plan with each offering's expected sailors, or the number `enrollment` gives it instead. */
74
+ function calculate(data, enrollment) {
18
75
  const ctx = new Context(data);
19
- for (const o of data.plan_offerings)
20
- ctx.enrollment.set(o.id, enrollmentOf(o));
76
+ for (const o of data.plan_offerings) {
77
+ ctx.enrollment.set(o.id, enrollment.get(o.id) ?? enrollmentOf(o));
78
+ ctx.maxEnrollment.set(o.id, maxEnrollmentOf(o));
79
+ }
21
80
  checkAreas(ctx);
22
81
  const terms = offeringTerms(ctx);
23
- const { activities, coaching, doubleBooked } = new Staffing(ctx).run(scheduleActivities(ctx));
82
+ const { activities, coaching, doubleBooked, holds } = new Staffing(ctx).run(scheduleActivities(ctx));
24
83
  countDays(ctx, activities);
25
84
  bookRevenue(ctx);
26
85
  const booker = new CostCenterBooker(ctx);
@@ -43,7 +102,7 @@ export function computePlan(data) {
43
102
  const organization = organizationTotals(ctx, lines);
44
103
  return {
45
104
  lines,
46
- pnl: summarize(ctx, lines, mtdcByOffering, terms),
105
+ pnl: summarize(ctx, lines, mtdcByOffering, holds, terms),
47
106
  organization,
48
107
  activities,
49
108
  positions: summarizePositions(ctx, coaching, charged, bonusByPosition, doubleBooked),
@@ -183,8 +242,8 @@ function sumLines(ctx) {
183
242
  .filter((l) => l.amount_cents !== 0);
184
243
  }
185
244
  /** Each plan-year offering's P&L, then each product's, then each program area's, then each area's terms. */
186
- function summarize(ctx, lines, mtdcByOffering, terms) {
187
- const pnl = (level, key, own, counts) => {
245
+ function summarize(ctx, lines, mtdcByOffering, holds, terms) {
246
+ const pnl = (level, key, own, counts, limits = { max_enrollment: null, allocation_holds: null }) => {
188
247
  const byAccount = new Map();
189
248
  let revenue = 0;
190
249
  let expense = 0;
@@ -207,13 +266,15 @@ function summarize(ctx, lines, mtdcByOffering, terms) {
207
266
  .map(([account_id, amount_cents]) => ({ account_id, amount_cents }))
208
267
  .sort((a, b) => a.account_id.localeCompare(b.account_id)),
209
268
  ...counts,
269
+ ...limits,
270
+ breakeven_enrollment: null,
210
271
  };
211
272
  };
212
273
  const offerings = [...ctx.data.plan_offerings].sort((a, b) => a.key.localeCompare(b.key));
213
274
  const offeringPnl = offerings.map((o) => {
214
275
  const enrollment = ctx.enrollment.get(o.id) ?? 0;
215
276
  const days = ctx.days.get(o.key) ?? 0;
216
- return pnl("offering", o.key, lines.filter((l) => l.cost_center_type === "offering" && l.cost_center_key === o.key), { mtdc_cents: Math.round(mtdcByOffering.get(o.key) ?? 0), enrollment, days, sailor_days: enrollment * days });
277
+ return pnl("offering", o.key, lines.filter((l) => l.cost_center_type === "offering" && l.cost_center_key === o.key), { mtdc_cents: Math.round(mtdcByOffering.get(o.key) ?? 0), enrollment, days, sailor_days: enrollment * days }, { max_enrollment: ctx.maxEnrollment.get(o.id) ?? 0, allocation_holds: holds.get(o.id) ?? null });
217
278
  });
218
279
  const productIds = new Set(offerings.map((o) => o.product_id));
219
280
  for (const l of lines)
@@ -0,0 +1,9 @@
1
+ /** A Financial Plan, as far as which one is in force: the date it's active from, if it's active. */
2
+ export interface Activatable {
3
+ effective_from: string | null;
4
+ }
5
+ /**
6
+ * The plan in force on `on` (`YYYY-MM-DD`): of one organization's year's Financial Plans, the one with
7
+ * the latest date on or before it. None before the first one's date.
8
+ */
9
+ export declare function planInForce<T extends Activatable>(sets: readonly T[], on: string): T | undefined;
@@ -0,0 +1,14 @@
1
+ /**
2
+ * The plan in force on `on` (`YYYY-MM-DD`): of one organization's year's Financial Plans, the one with
3
+ * the latest date on or before it. None before the first one's date.
4
+ */
5
+ export function planInForce(sets, on) {
6
+ let inForce;
7
+ for (const set of sets) {
8
+ if (set.effective_from === null || set.effective_from > on)
9
+ continue;
10
+ if (inForce === undefined || set.effective_from > inForce.effective_from)
11
+ inForce = set;
12
+ }
13
+ return inForce;
14
+ }
@@ -1,3 +1,5 @@
1
1
  export { computePlan, perDay, perSailorDay } from "./engine.ts";
2
2
  export type * from "./types.ts";
3
3
  export { PROGRAM_PALETTE, programColor } from "./colors.ts";
4
+ export { planInForce } from "./in-force.ts";
5
+ export type { Activatable } from "./in-force.ts";
@@ -1,2 +1,3 @@
1
1
  export { computePlan, perDay, perSailorDay } from "./engine.js";
2
2
  export { PROGRAM_PALETTE, programColor } from "./colors.js";
3
+ export { planInForce } from "./in-force.js";
@@ -55,6 +55,29 @@ function checkCostCenter(ctx, type, key, line) {
55
55
  ctx.warn("unknown_cost_center", `Line ${line} is charged to ${type.replace("_", " ")} ${key ?? ""}, which isn't in the plan`, line);
56
56
  return known;
57
57
  }
58
+ /**
59
+ * Warns about a Program Plan line that moves a cost off its area's programs, for a Plan Admin to
60
+ * look over before making the plan active (cyc-seattle/platform#251). Only a Plan Admin changes the
61
+ * Operations Plan, so its lines never get these. Capital charged anywhere but the
62
+ * organization already has its own warning, `capital_off_org`.
63
+ */
64
+ function warnCostShift(ctx, line) {
65
+ if (ctx.planOfVersion.get(line.version_id)?.kind !== "program_area")
66
+ return;
67
+ const warn = (message) => ctx.warn("cost_off_programs", message, line.key, { version_id: line.version_id });
68
+ const org = ctx.data.organization.name;
69
+ const capital = ctx.accountType(line.account_id) === "asset";
70
+ if (capital && line.cost_center_type === "org") {
71
+ warn(`${line.name} is capital, so ${org} pays for it, not this area's programs`);
72
+ }
73
+ else if (line.cost_center_type === "org")
74
+ warn(`${line.name} is charged to ${org}, not to this area's programs`);
75
+ else if (line.cost_center_type === "pool")
76
+ warn(`${line.name} is charged to shared costs, which every program area pays`);
77
+ if (line.in_mtdc === false) {
78
+ warn(`${line.name} is left out of the split of shared costs, so the other program areas pay more of them`);
79
+ }
80
+ }
58
81
  /**
59
82
  * Records the plan year's `plan_budget_lines`. Capital, a line on an asset account, is the
60
83
  * organization's: fundraising pays for it, so it never reaches a program area or an offering.
@@ -67,6 +90,7 @@ export function bookBudgetLines(ctx, booker) {
67
90
  continue;
68
91
  const capital = ctx.accountType(line.account_id) === "asset";
69
92
  const onOrg = line.cost_center_type === "org" || capital;
93
+ warnCostShift(ctx, line);
70
94
  if (capital && line.cost_center_type !== "org") {
71
95
  ctx.warn("capital_off_org", `${line.name} is capital, so it's charged to ${ctx.data.organization.name}, which pays for all capital`, line.key);
72
96
  }
@@ -89,6 +113,7 @@ export function bookLaborLines(ctx, booker) {
89
113
  for (const line of ctx.data.plan_labor_lines) {
90
114
  if (!checkCostCenter(ctx, line.cost_center_type, line.cost_center_key, line.key))
91
115
  continue;
116
+ warnCostShift(ctx, line);
92
117
  const level = levels.get(line.staff_level_key);
93
118
  if (level === undefined) {
94
119
  ctx.warn("unknown_staff_level", `Labor line ${line.key} has a staff level that isn't in the Operations Plan, so it's left out`, line.key);
@@ -18,8 +18,10 @@ export interface DoubleBooked {
18
18
  /** Dates it's on two blocks at once. */
19
19
  dates: number;
20
20
  }
21
- /** Whole kids: `target_enrollment` if set, otherwise `capacity × fill_rate` rounded. */
21
+ /** Whole kids expected: `target_enrollment` if set, otherwise `capacity × fill_rate` rounded. */
22
22
  export declare function enrollmentOf(offering: PlanOffering): number;
23
+ /** The most sailors an offering takes: its `capacity`, or else the sailors expected. */
24
+ export declare function maxEnrollmentOf(offering: PlanOffering): number;
23
25
  export declare class Staffing {
24
26
  private readonly ctx;
25
27
  private readonly warned;
@@ -37,12 +39,20 @@ export declare class Staffing {
37
39
  /** Each program's settings, by its product. */
38
40
  private programs;
39
41
  /**
40
- * Warns when a block's sailors are more than its staff or its boats can safely take. Each program's
42
+ * Warns when a block's sailors, with every offering full, are more than its staff or its boats can
43
+ * safely take, and records the most sailors of each offering the block can take. Each program's
41
44
  * sailors count against its own sailors per coach, and every coach on the block counts, at any
42
45
  * staff level or salaried: the ratio is required, a coach's level isn't (cyc-seattle/sail-team#82).
43
46
  * Boats count the ones sailors bring.
44
47
  */
45
48
  private checkCapacity;
49
+ /**
50
+ * The coaches a block pays for its expected sailors: Σ (each program's sailors ÷ its sailors per
51
+ * coach), rounded up, but never more than it's given. Salaried coaches on salary are always paid, so
52
+ * only the others are cut, each staff row by the same share. When a program has no sailors per
53
+ * coach, the block pays all it's given.
54
+ */
55
+ private coachesExpected;
46
56
  /** Books a cost on a block, split among its activities by their offerings' sailors. */
47
57
  private book;
48
58
  /**
@@ -66,10 +76,12 @@ export declare class Staffing {
66
76
  activities: ComputedActivity[];
67
77
  coaching: SalariedCoaching[];
68
78
  doubleBooked: Map<Id, DoubleBooked>;
79
+ /** The most sailors each offering id's blocks can take. */
80
+ holds: Map<Id, number>;
69
81
  };
70
82
  /**
71
- * A trip's coaches each fly and stay; the miles are driven once. Its coaches are the staff on the
72
- * block of its busiest travel day.
83
+ * A trip's coaches each fly and stay; the miles are driven once. Its coaches are the ones its
84
+ * expected sailors need on the block of its busiest travel day.
73
85
  */
74
86
  private bookTrips;
75
87
  }
@@ -1,9 +1,15 @@
1
- /** Whole kids: `target_enrollment` if set, otherwise `capacity × fill_rate` rounded. */
1
+ /** Whole kids expected: `target_enrollment` if set, otherwise `capacity × fill_rate` rounded. */
2
2
  export function enrollmentOf(offering) {
3
3
  if (offering.target_enrollment != null)
4
4
  return Math.round(offering.target_enrollment);
5
5
  return Math.round((offering.capacity ?? 0) * (offering.fill_rate ?? 0));
6
6
  }
7
+ /** The most sailors an offering takes: its `capacity`, or else the sailors expected. */
8
+ export function maxEnrollmentOf(offering) {
9
+ return offering.capacity ?? enrollmentOf(offering);
10
+ }
11
+ /** `n` rounded up, so a share like 5.0000001 boats stays 5. */
12
+ const ceil = (n) => Math.ceil(n - 1e-9);
7
13
  function sum(values) {
8
14
  let total = 0;
9
15
  for (const v of values)
@@ -93,6 +99,7 @@ export class Staffing {
93
99
  const byOffering = groupBy(list, (a) => a.offering.id);
94
100
  const total = sum([...byOffering.values()].map((own) => enrollment(own[0])));
95
101
  const share = (a) => (total > 0 ? enrollment(a) / total : 1 / byOffering.size) / byOffering.get(a.offering.id).length;
102
+ const most = sum([...byOffering.keys()].map((o) => this.ctx.maxEnrollment.get(o) ?? 0));
96
103
  result.set(id, {
97
104
  row,
98
105
  members: list,
@@ -104,6 +111,7 @@ export class Staffing {
104
111
  staff: staff.get(id) ?? [],
105
112
  boats: boats.get(id) ?? [],
106
113
  shares: new Map(list.map((a) => [a, share(a)])),
114
+ fill: most > 0 ? Math.min(1, total / most) : 1,
107
115
  });
108
116
  }
109
117
  return result;
@@ -127,61 +135,98 @@ export class Staffing {
127
135
  return new Map(this.ctx.data.plan_programs.map((p) => [p.product_id, p]));
128
136
  }
129
137
  /**
130
- * Warns when a block's sailors are more than its staff or its boats can safely take. Each program's
138
+ * Warns when a block's sailors, with every offering full, are more than its staff or its boats can
139
+ * safely take, and records the most sailors of each offering the block can take. Each program's
131
140
  * sailors count against its own sailors per coach, and every coach on the block counts, at any
132
141
  * staff level or salaried: the ratio is required, a coach's level isn't (cyc-seattle/sail-team#82).
133
142
  * Boats count the ones sailors bring.
134
143
  */
135
- checkCapacity(block, programs) {
144
+ checkCapacity(block, programs, holds) {
136
145
  const { ctx } = this;
137
146
  const offerings = [
138
147
  ...new Map(block.members.filter((a) => a.kind !== "travel").map((a) => [a.offering.id, a.offering])).values(),
139
148
  ];
140
- const sailors = sum(offerings.map((o) => ctx.enrollment.get(o.id) ?? 0));
141
- if (sailors <= 0)
142
- return;
149
+ const most = (o) => ctx.maxEnrollment.get(o.id) ?? 0;
150
+ const sailors = sum(offerings.map(most));
151
+ const warns = sailors > 0;
143
152
  const scope = { version_id: block.row.version_id, date: block.row.date };
144
- // The block's offerings, with the verb that agrees with them: "Guppies Week 2 has".
145
- const has = `${offeringsOf(block)} ${new Set(block.members.map((a) => a.offering.id)).size > 1 ? "have" : "has"}`;
146
- // Coaches the block needs: each program's sailors over its sailors per coach.
147
- let load = 0;
148
- let counted = 0;
153
+ // The block's offerings, with the verbs that agree with them: "Guppies Week 2 is for", "has".
154
+ const several = new Set(block.members.map((a) => a.offering.id)).size > 1;
155
+ const is = `${offeringsOf(block)} ${several ? "are" : "is"} for up to`;
156
+ const has = several ? "have" : "has";
157
+ // Coaches each program's full number of sailors needs.
158
+ const needs = new Map();
159
+ const perCoach = new Map();
149
160
  for (const o of offerings) {
150
161
  const settings = programs.get(o.product_id);
151
162
  if (settings?.staff_level_key == null || settings.sailors_per_coach == null || settings.sailors_per_coach <= 0) {
152
- this.warnOnce("no_program_settings", `${o.name}'s program has no staff level or sailors per coach. Add both to check that it has enough coaches`, o.product_id, { version_id: o.version_id, date: block.row.date });
163
+ if (warns) {
164
+ this.warnOnce("no_program_settings", `${o.name}'s program has no staff level or sailors per coach. Add both to check that it has enough coaches`, o.product_id, { version_id: o.version_id, date: block.row.date });
165
+ }
153
166
  continue;
154
167
  }
155
- const n = ctx.enrollment.get(o.id) ?? 0;
156
- load += n / settings.sailors_per_coach;
157
- counted += n;
168
+ needs.set(o.id, most(o) / settings.sailors_per_coach);
169
+ perCoach.set(o.id, settings.sailors_per_coach);
158
170
  }
159
- if (counted > 0) {
160
- const covered = Math.min(load, sum(block.staff.map((s) => s.count)));
161
- if (load > covered + 1e-9) {
162
- ctx.warn("over_capacity", covered === 0
163
- ? `${has} ${countOf(counted, "sailor")} and no coach`
164
- : `${has} ${countOf(counted, "sailor")}, but enough coaches for only ${Math.floor((covered * counted) / load + 1e-9)}`, block.row.key, scope);
165
- }
171
+ const coaches = sum(block.staff.map((s) => s.count));
172
+ const load = sum(needs.values());
173
+ const counted = sum(offerings.filter((o) => needs.has(o.id)).map(most));
174
+ if (warns && counted > 0 && load > coaches + 1e-9) {
175
+ ctx.warn("over_capacity", coaches === 0
176
+ ? `${is} ${countOf(counted, "sailor")} and ${has} no coach`
177
+ : `${is} ${countOf(counted, "sailor")}, but ${has} enough coaches for only ${Math.floor((coaches * counted) / load + 1e-9)}`, block.row.key, scope);
166
178
  }
167
- if (!block.members.some((a) => a.uses_boats))
168
- return;
169
- const classes = new Map(ctx.data.boat_classes.map((c) => [c.id, c]));
179
+ const sails = block.members.some((a) => a.uses_boats);
170
180
  let seats = 0;
171
- for (const b of block.boats) {
172
- // A coach boat's class takes 0, without a warning.
173
- const perBoat = classes.get(b.boat_class_id)?.max_capacity;
174
- if (perBoat == null) {
175
- this.warnOnce("no_max_capacity", `${classes.get(b.boat_class_id)?.name ?? b.boat_class_id} has no max capacity. Add it to check that the boats hold every sailor`, b.boat_class_id, scope);
176
- continue;
181
+ if (sails) {
182
+ const classes = new Map(ctx.data.boat_classes.map((c) => [c.id, c]));
183
+ for (const b of block.boats) {
184
+ // A coach boat's class takes 0, without a warning.
185
+ const perBoat = classes.get(b.boat_class_id)?.max_capacity;
186
+ if (perBoat == null) {
187
+ if (warns) {
188
+ this.warnOnce("no_max_capacity", `${classes.get(b.boat_class_id)?.name ?? b.boat_class_id} has no max capacity. Add it to check that the boats hold every sailor`, b.boat_class_id, scope);
189
+ }
190
+ continue;
191
+ }
192
+ seats += (b.count + (b.chartered ?? 0) + (b.sailor_owned ?? 0)) * perBoat;
193
+ }
194
+ if (warns && sailors > seats) {
195
+ ctx.warn("over_capacity", seats === 0
196
+ ? `${is} ${countOf(sailors, "sailor")} and ${has} no boats`
197
+ : `${is} ${countOf(sailors, "sailor")}, but the boats hold only ${seats}`, block.row.key, scope);
177
198
  }
178
- seats += (b.count + (b.chartered ?? 0) + (b.sailor_owned ?? 0)) * perBoat;
179
199
  }
180
- if (sailors > seats) {
181
- ctx.warn("over_capacity", seats === 0
182
- ? `${has} ${countOf(sailors, "sailor")} and no boats`
183
- : `${has} ${countOf(sailors, "sailor")}, but the boats hold only ${seats}`, block.row.key, scope);
200
+ // What's left for each offering once the others in the block are full.
201
+ for (const o of offerings) {
202
+ const own = needs.get(o.id);
203
+ const byCoaches = own === undefined ? Infinity : Math.floor((coaches - (load - own)) * perCoach.get(o.id) + 1e-9);
204
+ const byBoats = sails ? seats - (sailors - most(o)) : Infinity;
205
+ const n = Math.min(byCoaches, byBoats);
206
+ if (n === Infinity)
207
+ continue;
208
+ holds.set(o.id, Math.min(holds.get(o.id) ?? Infinity, Math.max(0, n)));
209
+ }
210
+ }
211
+ /**
212
+ * The coaches a block pays for its expected sailors: Σ (each program's sailors ÷ its sailors per
213
+ * coach), rounded up, but never more than it's given. Salaried coaches on salary are always paid, so
214
+ * only the others are cut, each staff row by the same share. When a program has no sailors per
215
+ * coach, the block pays all it's given.
216
+ */
217
+ coachesExpected(block, programs) {
218
+ const given = sum(block.staff.map((s) => s.count));
219
+ const salaried = sum(block.staff.filter((s) => s.position_key != null && s.pay !== "hourly").map((s) => s.count));
220
+ let load = 0;
221
+ for (const o of new Map(block.members.map((a) => [a.offering.id, a.offering])).values()) {
222
+ const perCoach = programs.get(o.product_id)?.sailors_per_coach;
223
+ if (perCoach == null || perCoach <= 0)
224
+ return { coaches: given, share: 1 };
225
+ load += (this.ctx.enrollment.get(o.id) ?? 0) / perCoach;
184
226
  }
227
+ const others = given - salaried;
228
+ const kept = Math.min(others, Math.max(0, ceil(load) - salaried));
229
+ return { coaches: salaried + kept, share: others > 0 ? kept / others : 1 };
185
230
  }
186
231
  /** Books a cost on a block, split among its activities by their offerings' sailors. */
187
232
  book(block, account, amount, line, name, explanation, mtdcShare = 1, wages = false) {
@@ -212,7 +257,11 @@ export class Staffing {
212
257
  const rates = new Map(ctx.data.plan_boat_rates.map((r) => [r.boat_class_id, r]));
213
258
  const classes = new Map(ctx.data.boat_classes.map((c) => [c.id, c]));
214
259
  for (const list of groupBy(blocks, (b) => b.row.date).values()) {
215
- const chartered = list.flatMap((block) => block.boats.flatMap((b) => ((b.chartered ?? 0) > 0 ? [{ block, id: b.boat_class_id, n: b.chartered }] : [])));
260
+ const chartered = list.flatMap((block) => block.boats.flatMap((b) => {
261
+ // The boats the expected sailors use, as for its own boats.
262
+ const n = ceil((b.chartered ?? 0) * block.fill);
263
+ return n > 0 ? [{ block, id: b.boat_class_id, n }] : [];
264
+ }));
216
265
  for (const [id, uses] of groupBy(chartered, (c) => c.id)) {
217
266
  const name = classes.get(id)?.name ?? id;
218
267
  const rate = rates.get(id)?.charter_per_day_cents;
@@ -307,9 +356,10 @@ export class Staffing {
307
356
  const blocks = this.blocks(scheduled);
308
357
  this.checkUnallocated(scheduled, blocks);
309
358
  const programs = this.programs();
359
+ const holds = new Map();
310
360
  // By date, so a warning given once names the first date it's seen on.
311
361
  for (const block of byDate(blocks))
312
- this.checkCapacity(block, programs);
362
+ this.checkCapacity(block, programs, holds);
313
363
  this.checkInventory(byDate(blocks));
314
364
  const doubleBooked = this.checkDoubleBooked([...blocks.values()]);
315
365
  const coaching = [];
@@ -318,10 +368,13 @@ export class Staffing {
318
368
  const boatClasses = new Map(ctx.data.boat_classes.map((c) => [c.id, c]));
319
369
  const boatRates = new Map(ctx.data.plan_boat_rates.map((r) => [r.boat_class_id, r]));
320
370
  const given = new Map();
371
+ const tripCoaches = new Map();
321
372
  for (const block of byDate(blocks)) {
322
373
  const scope = { version_id: block.row.version_id, date: block.row.date };
323
374
  const paidHours = block.paid_minutes / 60;
324
375
  const waterHours = block.on_water_minutes / 60;
376
+ const expected = this.coachesExpected(block, programs);
377
+ tripCoaches.set(block.row.id, expected.coaches);
325
378
  const book = (type, amount, line, name, explanation) => this.book(block, ctx.costAccount(type), amount, line, name, explanation, 1, type === "coach_wages");
326
379
  let wages = 0;
327
380
  for (const s of block.staff) {
@@ -329,8 +382,8 @@ export class Staffing {
329
382
  continue;
330
383
  if (s.position_key != null) {
331
384
  const position = positions.get(s.position_key);
332
- const hours = s.count * paidHours;
333
385
  if (s.pay === "hourly") {
386
+ const hours = s.count * expected.share * paidHours;
334
387
  const rate = position?.hourly_coaching_rate_cents;
335
388
  if (position !== undefined && rate == null) {
336
389
  this.warnOnce("no_hourly_rate", `${position.name} is paid by the hour to coach but has no own hourly coaching rate, so those hours cost nothing`, position.key, scope);
@@ -347,6 +400,7 @@ export class Staffing {
347
400
  }
348
401
  }
349
402
  else {
403
+ const hours = s.count * paidHours;
350
404
  for (const [a, share] of block.shares) {
351
405
  coaching.push({
352
406
  position_key: s.position_key,
@@ -364,16 +418,19 @@ export class Staffing {
364
418
  this.warnOnce("unknown_staff_level", `${block.row.name} has staff at a staff level that isn't in the Operations Plan, so they cost nothing`, s.staff_level_key ?? s.key, scope);
365
419
  continue;
366
420
  }
367
- if (level.pay_type !== "volunteer")
368
- wages += s.count * paidHours * (level.hourly_rate_cents ?? 0);
421
+ if (level.pay_type !== "volunteer") {
422
+ wages += s.count * expected.share * paidHours * (level.hourly_rate_cents ?? 0);
423
+ }
369
424
  }
370
- book("coach_wages", wages, "coach_wages", "Coach wages", "Staff × paid hours × each one's hourly rate");
425
+ book("coach_wages", wages, "coach_wages", "Coach wages", "Coaches the expected sailors need × paid hours × each one's hourly rate");
371
426
  for (const b of block.boats) {
372
427
  const rate = boatRates.get(b.boat_class_id);
373
428
  if (rate === undefined || b.count <= 0 || waterHours <= 0)
374
429
  continue;
375
430
  const boatClass = boatClasses.get(b.boat_class_id);
376
- this.book(block, boatClass?.account_id ?? ctx.costAccount("boat_maintenance"), b.count * waterHours * (rate.maintenance_per_boat_hour_cents + (rate.fuel_per_hour_cents ?? 0)), `boats:${b.boat_class_id}`, `Boats: ${boatClass?.name ?? "boats"}`, "The organization's boats × on-water hours × the class's fuel and upkeep");
431
+ this.book(block, boatClass?.account_id ?? ctx.costAccount("boat_maintenance"), ceil(b.count * block.fill) *
432
+ waterHours *
433
+ (rate.maintenance_per_boat_hour_cents + (rate.fuel_per_hour_cents ?? 0)), `boats:${b.boat_class_id}`, `Boats: ${boatClass?.name ?? "boats"}`, "The organization's boats the expected sailors use × on-water hours × the class's fuel and upkeep");
377
434
  }
378
435
  const coaches = sum(block.staff.map((s) => s.count));
379
436
  const boats = sum(block.boats.map((b) => b.count + (b.chartered ?? 0)));
@@ -382,10 +439,11 @@ export class Staffing {
382
439
  }
383
440
  }
384
441
  this.bookCharters(byDate(blocks));
385
- this.bookTrips(blocks, scheduled);
442
+ this.bookTrips(blocks, scheduled, tripCoaches);
386
443
  return {
387
444
  coaching,
388
445
  doubleBooked,
446
+ holds,
389
447
  activities: scheduled.map((a) => ({
390
448
  offering_id: a.offering.id,
391
449
  offering_key: a.offering.key,
@@ -403,10 +461,10 @@ export class Staffing {
403
461
  };
404
462
  }
405
463
  /**
406
- * A trip's coaches each fly and stay; the miles are driven once. Its coaches are the staff on the
407
- * block of its busiest travel day.
464
+ * A trip's coaches each fly and stay; the miles are driven once. Its coaches are the ones its
465
+ * expected sailors need on the block of its busiest travel day.
408
466
  */
409
- bookTrips(blocks, scheduled) {
467
+ bookTrips(blocks, scheduled, coachesOf) {
410
468
  const { ctx } = this;
411
469
  for (const trip of ctx.data.plan_trips) {
412
470
  const offering = ctx.offeringsById.get(trip.offering_id);
@@ -414,8 +472,9 @@ export class Staffing {
414
472
  continue;
415
473
  const travel = (a) => a.kind === "travel" && a.trip?.id === trip.id;
416
474
  const staffed = [...blocks.values()].filter((b) => b.members.some(travel));
417
- const coaches = Math.max(0, ...staffed.map((b) => sum(b.staff.map((s) => s.count))));
418
- if (coaches === 0 && ((trip.airfare_cents ?? 0) > 0 || (trip.nights ?? 0) > 0)) {
475
+ const coaches = Math.max(0, ...staffed.map((b) => coachesOf.get(b.row.id) ?? 0));
476
+ const given = Math.max(0, ...staffed.map((b) => sum(b.staff.map((s) => s.count))));
477
+ if (given === 0 && ((trip.airfare_cents ?? 0) > 0 || (trip.nights ?? 0) > 0)) {
419
478
  const first = scheduled
420
479
  .filter(travel)
421
480
  .map((a) => a.date)
@@ -367,6 +367,19 @@ export interface Pnl extends Totals {
367
367
  days: number;
368
368
  /** Enrollment × days. */
369
369
  sailor_days: number;
370
+ /** The most sailors an offering takes: its `capacity`, or else its enrollment. Null for a product or area. */
371
+ max_enrollment: number | null;
372
+ /**
373
+ * The most sailors an offering's blocks can take, with every other offering in them full. Null when
374
+ * it's in no block that limits it, and for a product or area.
375
+ */
376
+ allocation_holds: number | null;
377
+ /**
378
+ * The fewest sailors, from 1 up to its capacity, at which an offering's net after shared costs is
379
+ * zero or more, with its share of shared costs as planned. Null when it isn't, even when full, when
380
+ * it has no capacity, and for a product or area.
381
+ */
382
+ breakeven_enrollment: number | null;
370
383
  }
371
384
  /** An activity with the staffing the engine gave it. */
372
385
  export interface ComputedActivity {
@@ -882,6 +882,10 @@ export interface PlanResultPnl {
882
882
  term_name: string | null;
883
883
  /** The term's first day, which puts the terms in order. */
884
884
  term_starts_on: string | null;
885
+ /** The most sailors an offering's blocks can take, with every other offering in them full. Empty for a product or program area. */
886
+ allocation_holds: number | null;
887
+ /** The fewest sailors, up to its capacity, at which an offering pays for itself, with its share of shared costs as planned. Empty when it doesn't even when full, and for a product or program area. */
888
+ breakeven_enrollment: number | null;
885
889
  }
886
890
  /** Each salaried position's pay for the year, in a Financial Plan. A draft's are worked out again a moment after each change to it, and a saved one's when it's saved, and then never change. */
887
891
  export interface PlanResultPositions {
@@ -1242,8 +1246,8 @@ export interface VersionSets {
1242
1246
  name: string;
1243
1247
  /** What it was for, such as "Reviewed with the board". You can change it after it's saved. */
1244
1248
  message: string | null;
1245
- /** Set it to saved to save the Financial Plan and all its plans. A saved Financial Plan never changes, apart from its name and note. Set a saved one to active to make it the one its organization runs for the year, which sets the one active before it back to saved. */
1246
- status: "draft" | "saved" | "active";
1249
+ /** Set it to saved to save the Financial Plan and all its plans. A saved Financial Plan never changes, apart from its name, its note, the date it's active from, and whether it's archived. */
1250
+ status: "draft" | "saved";
1247
1251
  /** The Financial Plan this one started as a copy of. Pick it when you add this one, and its plans are copied in. */
1248
1252
  parent_id: string | VersionSets | null;
1249
1253
  /** Set when the Financial Plan is saved. */
@@ -1252,7 +1256,9 @@ export interface VersionSets {
1252
1256
  saved_at: string | null;
1253
1257
  /** The version of the planning engine that calculated its numbers. */
1254
1258
  platform_version: string | null;
1255
- /** Set when the Financial Plan is archived, which hides it from Planning's list. Clear it to bring the plan back. An active Financial Plan can't be archived. */
1259
+ /** The day the Financial Plan is in force from. Leave it empty for one that isn't active. On any day, the plan in force is the one for the year with the latest date on or before that day. A draft can be active. No two Financial Plans for a year can share a date. */
1260
+ effective_from: string | null;
1261
+ /** Set when the Financial Plan is archived, which hides it from Planning's list. Clear it to bring the plan back. The plan in force can't be archived. */
1256
1262
  archived_at: string | null;
1257
1263
  /** Each plan in the Financial Plan. Add or remove one only while it's a draft. */
1258
1264
  members: (string | VersionSetMembers)[];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cyc-seattle/platform",
3
- "version": "1.52.0",
3
+ "version": "1.54.0",
4
4
  "description": "Pulumi components for CYC's shared Google Cloud platform: WebService, the platform outputs reader, and the stacks that build the platform",
5
5
  "author": "CYC Community Sailing Center",
6
6
  "license": "Apache-2.0",
@@ -81,6 +81,7 @@
81
81
  "@types/js-yaml": "4.0.9",
82
82
  "@types/luxon": "^3.7.6",
83
83
  "@types/node": "^22.19.1",
84
+ "commander": "13.1.0",
84
85
  "esbuild": "0.28.2",
85
86
  "eslint": "^9.39.5",
86
87
  "google-auth-library": "^11.3.0",