@cyc-seattle/platform 1.54.0 → 1.55.1

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
@@ -13,9 +13,9 @@ platform is built, its data model, and the planning engine.
13
13
  | ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------ |
14
14
  | [`bootstrap`](stacks/bootstrap) | Service accounts and project IAM, the Pulumi state buckets and KMS keys, the org's GitHub repos and settings, and Firebase on the project | A human with project Owner and `gh` `admin:org`. Never CI. |
15
15
  | [`infra`](stacks/infra) | The VPC, the cluster subnet and its Cloud NAT on a static address, the private services peering, the prod and dev Cloud SQL instances, the DNS zones, Artifact Registry and its cleanup policy, the GKE Autopilot cluster, the shared Gateway, its address, certificates, and dev's DNS records, the `dev` and `prod` namespaces with their quotas and network policies, dev's RBAC, the prod Job alerts, the log exclusions, the public website's Firebase Hosting site, and each environment's `platform/outputs.json` | `deploy-runner`, from Actions after approval. A deployer, with `mise run deploy`, when breaking glass. |
16
- | [`smoke`](stacks/smoke) | One `WebService` in dev, at `smoke.dev.cycsail.team`, for the integration tests | `deploy-dev`, from Actions for each queued PR. A deployer, with `PULUMI_STACK=dev mise run deploy`, when breaking glass. |
17
- | [`backend`](stacks/backend) | Directus, with the schema apply from `schemas/`, its roles, policies, and permission rules, the sync and website build machine users, its OIDC client, and MCP, and the clubspot-sync and gsuite-sync CronJobs, with dev's Clubspot and Workspace fakes | `deploy-dev`, from Actions for each queued PR, and `deploy-runner` on a push to `main`, after `infra`. |
18
- | [`authentik`](stacks/authentik) | Authentik, the sign-in at `login.<domain>`: its server and worker, database, secrets, and A record, dev's Mailpit, and its own configuration: flows, stages, policies, the Google source, the `staff` group, the `Admins` superuser group, and the Directus OIDC and portal proxy applications | `deploy-dev`, from Actions for each queued PR, and `deploy-runner` on a push to `main`, after `backend`. |
16
+ | [`smoke`](stacks/smoke) | One `WebService` in dev, at `smoke.dev.cycsail.team`, for the integration tests | `deploy-dev`, from Actions for each merged PR. A deployer, with `PULUMI_STACK=dev mise run deploy`, when breaking glass. |
17
+ | [`backend`](stacks/backend) | Directus, with the schema apply from `schemas/`, its roles, policies, and permission rules, the sync and website build machine users, its OIDC client, and MCP, and the clubspot-sync and gsuite-sync CronJobs, with dev's Clubspot and Workspace fakes | `deploy-dev` and then `deploy-runner`, from Actions on a push to `main`, after `infra`. |
18
+ | [`authentik`](stacks/authentik) | Authentik, the sign-in at `login.<domain>`: its server and worker, database, secrets, and A record, dev's Mailpit, and its own configuration: flows, stages, policies, the Google source, the `staff` group, the `Admins` superuser group, and the Directus OIDC and portal proxy applications | `deploy-dev` and then `deploy-runner`, from Actions on a push to `main`, after `backend`. |
19
19
 
20
20
  ## @cyc-seattle/platform
21
21
 
@@ -104,20 +104,18 @@ deployer needs first.
104
104
  authenticates with Workload Identity Federation and stores no credentials. Each job runs in a
105
105
  GitHub environment that holds its identity and state bucket:
106
106
 
107
- | Event | Jobs, in order |
108
- | -------------------------- | ----------------------------------------------------------------------- |
109
- | Pull request | `ci` |
110
- | Merge queue | `ci`, then `dev`: `mise run deploy` as `deploy-dev`; `dev-passed` |
111
- | Push to `main` | `prod-preview`, then `prod`, which waits for the `maintainers` approval |
112
- | Run by hand, from a branch | `ci`, then `dev` |
113
- | Run by hand, from `main` | `prod-preview`, then `prod` |
114
-
115
- Dev is shared, so only the merge queue deploys to it (#69, #80). PRs queued together deploy as
116
- one batch: each queued PR's run skips dev when a newer PR is queued behind it, and the newest
117
- deploys them all. The queue requires `dev-passed`, so a batch merges only once its dev deploy passes, and `main` gets the exact commit the queue tested, in one push. If the
118
- newest PR fails, the queue drops it and the rest of the batch deploys without it. A queued PR of only docs or CI skips `dev`. The queue
119
- doesn't run the integration tests (#161); a run by hand does. To try a
120
- branch on dev and test it before queueing it, run `gh workflow run pull-request.yml --ref <branch>`.
107
+ | Event | Jobs, in order |
108
+ | ------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------- |
109
+ | Pull request | `ci` |
110
+ | Push to `main` | `dev`: `mise run deploy` as `deploy-dev`, then `integration` and `prod-preview`, then `prod`, which waits for the `maintainers` approval |
111
+ | Run by hand, from `main` | The same as a push to `main` |
112
+
113
+ Dev is shared and takes only `main` (#280), so a PR's push runs only CI, and each push to `main`
114
+ deploys dev before prod. `integration` starts `integration.yml`, which runs the integration tests
115
+ against dev in a run of its own, one at a time. A newer run replaces one that's waiting, and a
116
+ waiting deploy goes ahead of it, so it tests whatever is newest on dev. A failure opens or comments
117
+ on the `Integration tests failed on dev` issue, and a green run closes it. `nightly.yml` still
118
+ deploys and tests `main` each night, with its own issue.
121
119
 
122
120
  `prod-preview` runs `mise run preview` as `deploy-runner` and writes the diff to the job summary.
123
121
  `prod` runs `mise run deploy` on the same commit once approved. It runs only when the preview shows
@@ -8,7 +8,7 @@ export function scheduleActivities(ctx) {
8
8
  const closed = new Map();
9
9
  for (const c of ctx.data.plan_closed_dates ?? []) {
10
10
  // The closed days are the Operations Plan's, not an area's.
11
- if (ctx.planOfVersion.get(c.version_id)?.kind === "program_area") {
11
+ if (ctx.planOf.get(c.plan_id)?.kind === "program_area") {
12
12
  ctx.warn("closed_date_in_area_plan", `${c.name} is a closed day in a Program Plan. Only the Operations Plan's closed days count`, c.key);
13
13
  continue;
14
14
  }
@@ -31,7 +31,7 @@ export function scheduleActivities(ctx) {
31
31
  const closedFor = closed.get(activity.date);
32
32
  if (closedFor !== undefined && activity.kind !== "travel") {
33
33
  ctx.warn("closed_date", `${offering.name}'s ${activity.kind} is on ${closedFor}, when ${ctx.data.organization.name} is closed`, activity.key, {
34
- version_id: activity.version_id,
34
+ plan_id: activity.plan_id,
35
35
  date: activity.date,
36
36
  });
37
37
  }
@@ -94,7 +94,7 @@ function areaOf(ctx, owner) {
94
94
  * order and then no term. An area plan's bonus counts only that area's products, and warns about any other.
95
95
  */
96
96
  function basesOf(ctx, owner, terms) {
97
- const plan = ctx.planOfVersion.get(owner.version_id);
97
+ const plan = ctx.planOf.get(owner.plan_id);
98
98
  const products = new Set();
99
99
  for (const row of ctx.data.plan_bonus_owner_products) {
100
100
  if (row.bonus_owner_id !== owner.id)
@@ -1,7 +1,7 @@
1
1
  import type { Account, AccountFunction, CostCenterType, CostType, DateString, EngineWarning, Id, PlanData, PlanOffering, Plan, PlanRates, Product, ProgramArea } from "./types.ts";
2
- /** Where a warning applies: a plan copy, and a date. */
2
+ /** Where a warning applies: a plan, and a date. */
3
3
  export interface WarningScope {
4
- version_id?: Id;
4
+ plan_id?: Id;
5
5
  date?: DateString;
6
6
  }
7
7
  /** The step that recorded an entry. Only `direct` entries count toward MTDC. */
@@ -31,8 +31,8 @@ export declare class Context {
31
31
  readonly accounts: Map<Id, Account>;
32
32
  readonly products: Map<Id, Product>;
33
33
  readonly areas: Map<Id, ProgramArea>;
34
- /** Each version's plan. */
35
- readonly planOfVersion: Map<Id, Plan>;
34
+ /** Each plan, by its id. */
35
+ readonly planOf: Map<Id, Plan>;
36
36
  readonly offeringsById: Map<Id, PlanOffering>;
37
37
  readonly offeringsByKey: Map<Id, PlanOffering>;
38
38
  /** Expected enrollment per offering id, which the costs and revenue use. */
@@ -24,8 +24,8 @@ export class Context {
24
24
  accounts;
25
25
  products;
26
26
  areas;
27
- /** Each version's plan. */
28
- planOfVersion;
27
+ /** Each plan, by its id. */
28
+ planOf;
29
29
  offeringsById;
30
30
  offeringsByKey;
31
31
  /** Expected enrollment per offering id, which the costs and revenue use. */
@@ -42,11 +42,7 @@ export class Context {
42
42
  this.accounts = new Map(data.accounts.map((a) => [a.id, a]));
43
43
  this.products = new Map(data.products.map((p) => [p.id, p]));
44
44
  this.areas = new Map(data.program_areas.map((a) => [a.id, a]));
45
- const plans = new Map(data.plans.map((p) => [p.id, p]));
46
- this.planOfVersion = new Map(data.plan_versions.flatMap((v) => {
47
- const plan = plans.get(v.plan_id);
48
- return plan === undefined ? [] : [[v.id, plan]];
49
- }));
45
+ this.planOf = new Map(data.plans.map((p) => [p.id, p]));
50
46
  this.offeringsById = new Map(data.plan_offerings.map((o) => [o.id, o]));
51
47
  this.offeringsByKey = new Map(data.plan_offerings.map((o) => [o.key, o]));
52
48
  this.rates = data.plan_rates[0];
@@ -65,7 +61,7 @@ export class Context {
65
61
  return account;
66
62
  }
67
63
  warn(code, message, key, scope = {}) {
68
- const same = (w) => w.code === code && w.message === message && w.version_id === scope.version_id;
64
+ const same = (w) => w.code === code && w.message === message && w.plan_id === scope.plan_id;
69
65
  if (this.warnings.some(same))
70
66
  return;
71
67
  const category = ALLOCATION_WARNINGS.has(code) ? "allocation" : "data";
@@ -74,7 +70,7 @@ export class Context {
74
70
  message,
75
71
  category,
76
72
  ...(key !== undefined && { key }),
77
- ...(scope.version_id !== undefined && { version_id: scope.version_id }),
73
+ ...(scope.plan_id !== undefined && { plan_id: scope.plan_id }),
78
74
  ...(scope.date !== undefined && { date: scope.date }),
79
75
  });
80
76
  }
@@ -127,7 +127,7 @@ function checkAreas(ctx) {
127
127
  else if (product.kind !== "program" && product.program_area_id != null) {
128
128
  ctx.warn("area_on_non_program", `${product.name} isn't a program but has a program area`, product.id);
129
129
  }
130
- const plan = ctx.planOfVersion.get(offering.version_id);
130
+ const plan = ctx.planOf.get(offering.plan_id);
131
131
  if (plan === undefined)
132
132
  continue;
133
133
  const wrong = plan.kind === "operations"
@@ -62,9 +62,9 @@ function checkCostCenter(ctx, type, key, line) {
62
62
  * organization already has its own warning, `capital_off_org`.
63
63
  */
64
64
  function warnCostShift(ctx, line) {
65
- if (ctx.planOfVersion.get(line.version_id)?.kind !== "program_area")
65
+ if (ctx.planOf.get(line.plan_id)?.kind !== "program_area")
66
66
  return;
67
- const warn = (message) => ctx.warn("cost_off_programs", message, line.key, { version_id: line.version_id });
67
+ const warn = (message) => ctx.warn("cost_off_programs", message, line.key, { plan_id: line.plan_id });
68
68
  const org = ctx.data.organization.name;
69
69
  const capital = ctx.accountType(line.account_id) === "asset";
70
70
  if (capital && line.cost_center_type === "org") {
@@ -34,12 +34,17 @@ export function bookSalaries(ctx, coaching, booker) {
34
34
  }
35
35
  return [
36
36
  b.product_id != null
37
- ? { cost_center_type: "product", cost_center_key: b.product_id, hours: b.hours, line: b.kind }
37
+ ? {
38
+ cost_center_type: "product",
39
+ cost_center_key: b.product_id,
40
+ hours: b.hours,
41
+ line: "management",
42
+ }
38
43
  : {
39
44
  cost_center_type: "program_area",
40
45
  cost_center_key: b.program_area_id,
41
46
  hours: b.hours,
42
- line: b.kind,
47
+ line: "management",
43
48
  },
44
49
  ];
45
50
  }),
@@ -18,7 +18,7 @@ export interface DoubleBooked {
18
18
  /** Dates it's on two blocks at once. */
19
19
  dates: number;
20
20
  }
21
- /** Whole kids expected: `target_enrollment` if set, otherwise `capacity × fill_rate` rounded. */
21
+ /** Whole kids expected: `target_enrollment`, or none when it's empty. */
22
22
  export declare function enrollmentOf(offering: PlanOffering): number;
23
23
  /** The most sailors an offering takes: its `capacity`, or else the sailors expected. */
24
24
  export declare function maxEnrollmentOf(offering: PlanOffering): number;
@@ -26,7 +26,7 @@ export declare class Staffing {
26
26
  private readonly ctx;
27
27
  private readonly warned;
28
28
  constructor(ctx: Context);
29
- /** Warns once for each row and plan copy, about the first date it's seen on. */
29
+ /** Warns once for each row and plan, about the first date it's seen on. */
30
30
  private warnOnce;
31
31
  /**
32
32
  * Gathers each block's activities, times and allocations. A block's paid time runs from its first
@@ -46,13 +46,6 @@ export declare class Staffing {
46
46
  * Boats count the ones sailors bring.
47
47
  */
48
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;
56
49
  /** Books a cost on a block, split among its activities by their offerings' sailors. */
57
50
  private book;
58
51
  /**
@@ -80,8 +73,8 @@ export declare class Staffing {
80
73
  holds: Map<Id, number>;
81
74
  };
82
75
  /**
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.
76
+ * A trip's coaches each fly and stay; the miles are driven once. Its coaches are the staff on the
77
+ * block of its busiest travel day.
85
78
  */
86
79
  private bookTrips;
87
80
  }
@@ -1,15 +1,11 @@
1
- /** Whole kids expected: `target_enrollment` if set, otherwise `capacity × fill_rate` rounded. */
1
+ /** Whole kids expected: `target_enrollment`, or none when it's empty. */
2
2
  export function enrollmentOf(offering) {
3
- if (offering.target_enrollment != null)
4
- return Math.round(offering.target_enrollment);
5
- return Math.round((offering.capacity ?? 0) * (offering.fill_rate ?? 0));
3
+ return Math.round(offering.target_enrollment ?? 0);
6
4
  }
7
5
  /** The most sailors an offering takes: its `capacity`, or else the sailors expected. */
8
6
  export function maxEnrollmentOf(offering) {
9
7
  return offering.capacity ?? enrollmentOf(offering);
10
8
  }
11
- /** `n` rounded up, so a share like 5.0000001 boats stays 5. */
12
- const ceil = (n) => Math.ceil(n - 1e-9);
13
9
  function sum(values) {
14
10
  let total = 0;
15
11
  for (const v of values)
@@ -51,9 +47,9 @@ export class Staffing {
51
47
  constructor(ctx) {
52
48
  this.ctx = ctx;
53
49
  }
54
- /** Warns once for each row and plan copy, about the first date it's seen on. */
50
+ /** Warns once for each row and plan, about the first date it's seen on. */
55
51
  warnOnce(code, message, key, scope = {}) {
56
- const id = `${code}|${key}|${scope.version_id ?? ""}`;
52
+ const id = `${code}|${key}|${scope.plan_id ?? ""}`;
57
53
  if (this.warned.has(id))
58
54
  return;
59
55
  this.warned.add(id);
@@ -80,7 +76,7 @@ export class Staffing {
80
76
  continue;
81
77
  }
82
78
  if (row.date !== a.date) {
83
- this.ctx.warn("block_date_mismatch", `${a.offering.name}'s ${a.kind} is in ${row.name}, which is on ${dayOf(row.date)}`, a.stored.key, { version_id: a.stored.version_id, date: a.date });
79
+ this.ctx.warn("block_date_mismatch", `${a.offering.name}'s ${a.kind} is in ${row.name}, which is on ${dayOf(row.date)}`, a.stored.key, { plan_id: a.stored.plan_id, date: a.date });
84
80
  }
85
81
  members.set(id, [...(members.get(id) ?? []), a]);
86
82
  }
@@ -99,7 +95,6 @@ export class Staffing {
99
95
  const byOffering = groupBy(list, (a) => a.offering.id);
100
96
  const total = sum([...byOffering.values()].map((own) => enrollment(own[0])));
101
97
  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));
103
98
  result.set(id, {
104
99
  row,
105
100
  members: list,
@@ -111,7 +106,6 @@ export class Staffing {
111
106
  staff: staff.get(id) ?? [],
112
107
  boats: boats.get(id) ?? [],
113
108
  shares: new Map(list.map((a) => [a, share(a)])),
114
- fill: most > 0 ? Math.min(1, total / most) : 1,
115
109
  });
116
110
  }
117
111
  return result;
@@ -125,7 +119,7 @@ export class Staffing {
125
119
  if (block !== undefined && sum(block.staff.map((s) => s.count)) > 0)
126
120
  continue;
127
121
  this.ctx.warn("unallocated_activity", `${a.offering.name}'s ${a.kind} has no staff`, a.stored.key, {
128
- version_id: a.stored.version_id,
122
+ plan_id: a.stored.plan_id,
129
123
  date: a.date,
130
124
  });
131
125
  }
@@ -149,7 +143,7 @@ export class Staffing {
149
143
  const most = (o) => ctx.maxEnrollment.get(o.id) ?? 0;
150
144
  const sailors = sum(offerings.map(most));
151
145
  const warns = sailors > 0;
152
- const scope = { version_id: block.row.version_id, date: block.row.date };
146
+ const scope = { plan_id: block.row.plan_id, date: block.row.date };
153
147
  // The block's offerings, with the verbs that agree with them: "Guppies Week 2 is for", "has".
154
148
  const several = new Set(block.members.map((a) => a.offering.id)).size > 1;
155
149
  const is = `${offeringsOf(block)} ${several ? "are" : "is"} for up to`;
@@ -161,7 +155,7 @@ export class Staffing {
161
155
  const settings = programs.get(o.product_id);
162
156
  if (settings?.staff_level_key == null || settings.sailors_per_coach == null || settings.sailors_per_coach <= 0) {
163
157
  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 });
158
+ 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, { plan_id: o.plan_id, date: block.row.date });
165
159
  }
166
160
  continue;
167
161
  }
@@ -208,26 +202,6 @@ export class Staffing {
208
202
  holds.set(o.id, Math.min(holds.get(o.id) ?? Infinity, Math.max(0, n)));
209
203
  }
210
204
  }
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;
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 };
230
- }
231
205
  /** Books a cost on a block, split among its activities by their offerings' sailors. */
232
206
  book(block, account, amount, line, name, explanation, mtdcShare = 1, wages = false) {
233
207
  if (amount === 0 || account === undefined)
@@ -257,17 +231,13 @@ export class Staffing {
257
231
  const rates = new Map(ctx.data.plan_boat_rates.map((r) => [r.boat_class_id, r]));
258
232
  const classes = new Map(ctx.data.boat_classes.map((c) => [c.id, c]));
259
233
  for (const list of groupBy(blocks, (b) => b.row.date).values()) {
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
- }));
234
+ const chartered = list.flatMap((block) => block.boats.flatMap((b) => ((b.chartered ?? 0) > 0 ? [{ block, id: b.boat_class_id, n: b.chartered }] : [])));
265
235
  for (const [id, uses] of groupBy(chartered, (c) => c.id)) {
266
236
  const name = classes.get(id)?.name ?? id;
267
237
  const rate = rates.get(id)?.charter_per_day_cents;
268
238
  if (rate == null) {
269
239
  for (const { block } of uses) {
270
- this.warnOnce("no_charter_rate", `${block.row.name} charters ${pluralOf(name)}, but the Operations Plan has no charter rate for them, so they cost nothing`, id, { version_id: block.row.version_id, date: block.row.date });
240
+ this.warnOnce("no_charter_rate", `${block.row.name} charters ${pluralOf(name)}, but the Operations Plan has no charter rate for them, so they cost nothing`, id, { plan_id: block.row.plan_id, date: block.row.date });
271
241
  }
272
242
  continue;
273
243
  }
@@ -305,9 +275,9 @@ export class Staffing {
305
275
  const message = peak.at.length === 1
306
276
  ? `${count} out at once for ${offeringsOf(peak.at[0].block)}, but ${organization} has ${only}`
307
277
  : `${count} out at once, but ${organization} has ${only}: ${listOf(peak.at.map(({ block, n }) => `${n} for ${offeringsOf(block)}`))}`;
308
- for (const version of new Set(peak.at.map(({ block }) => block.row.version_id))) {
278
+ for (const plan of new Set(peak.at.map(({ block }) => block.row.plan_id))) {
309
279
  ctx.warn(code, message, what, {
310
- version_id: version,
280
+ plan_id: plan,
311
281
  date: date,
312
282
  });
313
283
  }
@@ -343,9 +313,9 @@ export class Staffing {
343
313
  const hours = extraCoachMinutes(list) / 60;
344
314
  const total = result.get(key) ?? { hours: 0, dates: 0 };
345
315
  result.set(key, { hours: total.hours + hours, dates: total.dates + 1 });
346
- for (const version of new Set(list.map((b) => b.row.version_id))) {
316
+ for (const plan of new Set(list.map((b) => b.row.plan_id))) {
347
317
  this.ctx.warn("position_double_booked", `${names.get(key) ?? key} coaches ${clash[0].row.name} and ${clash[1].row.name} at the same time` +
348
- (hours > 0 ? `, so another coach is needed for ${round2(hours)} hours` : ""), key, { version_id: version, date });
318
+ (hours > 0 ? `, so another coach is needed for ${round2(hours)} hours` : ""), key, { plan_id: plan, date });
349
319
  }
350
320
  }
351
321
  return result;
@@ -368,13 +338,10 @@ export class Staffing {
368
338
  const boatClasses = new Map(ctx.data.boat_classes.map((c) => [c.id, c]));
369
339
  const boatRates = new Map(ctx.data.plan_boat_rates.map((r) => [r.boat_class_id, r]));
370
340
  const given = new Map();
371
- const tripCoaches = new Map();
372
341
  for (const block of byDate(blocks)) {
373
- const scope = { version_id: block.row.version_id, date: block.row.date };
342
+ const scope = { plan_id: block.row.plan_id, date: block.row.date };
374
343
  const paidHours = block.paid_minutes / 60;
375
344
  const waterHours = block.on_water_minutes / 60;
376
- const expected = this.coachesExpected(block, programs);
377
- tripCoaches.set(block.row.id, expected.coaches);
378
345
  const book = (type, amount, line, name, explanation) => this.book(block, ctx.costAccount(type), amount, line, name, explanation, 1, type === "coach_wages");
379
346
  let wages = 0;
380
347
  for (const s of block.staff) {
@@ -383,7 +350,7 @@ export class Staffing {
383
350
  if (s.position_key != null) {
384
351
  const position = positions.get(s.position_key);
385
352
  if (s.pay === "hourly") {
386
- const hours = s.count * expected.share * paidHours;
353
+ const hours = s.count * paidHours;
387
354
  const rate = position?.hourly_coaching_rate_cents;
388
355
  if (position !== undefined && rate == null) {
389
356
  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);
@@ -419,18 +386,16 @@ export class Staffing {
419
386
  continue;
420
387
  }
421
388
  if (level.pay_type !== "volunteer") {
422
- wages += s.count * expected.share * paidHours * (level.hourly_rate_cents ?? 0);
389
+ wages += s.count * paidHours * (level.hourly_rate_cents ?? 0);
423
390
  }
424
391
  }
425
- book("coach_wages", wages, "coach_wages", "Coach wages", "Coaches the expected sailors need × paid hours × each one's hourly rate");
392
+ book("coach_wages", wages, "coach_wages", "Coach wages", "Staff × paid hours × each one's hourly rate");
426
393
  for (const b of block.boats) {
427
394
  const rate = boatRates.get(b.boat_class_id);
428
395
  if (rate === undefined || b.count <= 0 || waterHours <= 0)
429
396
  continue;
430
397
  const boatClass = boatClasses.get(b.boat_class_id);
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");
398
+ 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");
434
399
  }
435
400
  const coaches = sum(block.staff.map((s) => s.count));
436
401
  const boats = sum(block.boats.map((b) => b.count + (b.chartered ?? 0)));
@@ -439,7 +404,7 @@ export class Staffing {
439
404
  }
440
405
  }
441
406
  this.bookCharters(byDate(blocks));
442
- this.bookTrips(blocks, scheduled, tripCoaches);
407
+ this.bookTrips(blocks, scheduled);
443
408
  return {
444
409
  coaching,
445
410
  doubleBooked,
@@ -461,10 +426,10 @@ export class Staffing {
461
426
  };
462
427
  }
463
428
  /**
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.
429
+ * A trip's coaches each fly and stay; the miles are driven once. Its coaches are the staff on the
430
+ * block of its busiest travel day.
466
431
  */
467
- bookTrips(blocks, scheduled, coachesOf) {
432
+ bookTrips(blocks, scheduled) {
468
433
  const { ctx } = this;
469
434
  for (const trip of ctx.data.plan_trips) {
470
435
  const offering = ctx.offeringsById.get(trip.offering_id);
@@ -472,14 +437,13 @@ export class Staffing {
472
437
  continue;
473
438
  const travel = (a) => a.kind === "travel" && a.trip?.id === trip.id;
474
439
  const staffed = [...blocks.values()].filter((b) => b.members.some(travel));
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)) {
440
+ const coaches = Math.max(0, ...staffed.map((b) => sum(b.staff.map((s) => s.count))));
441
+ if (coaches === 0 && ((trip.airfare_cents ?? 0) > 0 || (trip.nights ?? 0) > 0)) {
478
442
  const first = scheduled
479
443
  .filter(travel)
480
444
  .map((a) => a.date)
481
445
  .sort()[0];
482
- this.warnOnce("trip_without_staff", `${trip.name} has no staff on its travel days, so the plan has no airfare or lodging for its coaches`, trip.key, { version_id: trip.version_id, ...(first !== undefined && { date: first }) });
446
+ this.warnOnce("trip_without_staff", `${trip.name} has no staff on its travel days, so the plan has no airfare or lodging for its coaches`, trip.key, { plan_id: trip.plan_id, ...(first !== undefined && { date: first }) });
483
447
  }
484
448
  const lodging = ctx.rates?.lodging_per_night_cents ?? null;
485
449
  const mileage = ctx.rates?.mileage_rate_cents ?? null;