@cyc-seattle/platform 1.55.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 +15 -16
- package/dist/planning/staffing.d.ts +2 -9
- package/dist/planning/staffing.js +11 -45
- package/package.json +1 -1
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
|
|
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
|
|
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
|
|
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,19 +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
|
-
|
|
|
111
|
-
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
(#280), so a branch can't deploy to it.
|
|
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.
|
|
120
119
|
|
|
121
120
|
`prod-preview` runs `mise run preview` as `deploy-runner` and writes the diff to the job summary.
|
|
122
121
|
`prod` runs `mise run deploy` on the same commit once approved. It runs only when the preview shows
|
|
@@ -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
|
|
84
|
-
*
|
|
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
|
}
|
|
@@ -6,8 +6,6 @@ export function enrollmentOf(offering) {
|
|
|
6
6
|
export function maxEnrollmentOf(offering) {
|
|
7
7
|
return offering.capacity ?? enrollmentOf(offering);
|
|
8
8
|
}
|
|
9
|
-
/** `n` rounded up, so a share like 5.0000001 boats stays 5. */
|
|
10
|
-
const ceil = (n) => Math.ceil(n - 1e-9);
|
|
11
9
|
function sum(values) {
|
|
12
10
|
let total = 0;
|
|
13
11
|
for (const v of values)
|
|
@@ -97,7 +95,6 @@ export class Staffing {
|
|
|
97
95
|
const byOffering = groupBy(list, (a) => a.offering.id);
|
|
98
96
|
const total = sum([...byOffering.values()].map((own) => enrollment(own[0])));
|
|
99
97
|
const share = (a) => (total > 0 ? enrollment(a) / total : 1 / byOffering.size) / byOffering.get(a.offering.id).length;
|
|
100
|
-
const most = sum([...byOffering.keys()].map((o) => this.ctx.maxEnrollment.get(o) ?? 0));
|
|
101
98
|
result.set(id, {
|
|
102
99
|
row,
|
|
103
100
|
members: list,
|
|
@@ -109,7 +106,6 @@ export class Staffing {
|
|
|
109
106
|
staff: staff.get(id) ?? [],
|
|
110
107
|
boats: boats.get(id) ?? [],
|
|
111
108
|
shares: new Map(list.map((a) => [a, share(a)])),
|
|
112
|
-
fill: most > 0 ? Math.min(1, total / most) : 1,
|
|
113
109
|
});
|
|
114
110
|
}
|
|
115
111
|
return result;
|
|
@@ -206,26 +202,6 @@ export class Staffing {
|
|
|
206
202
|
holds.set(o.id, Math.min(holds.get(o.id) ?? Infinity, Math.max(0, n)));
|
|
207
203
|
}
|
|
208
204
|
}
|
|
209
|
-
/**
|
|
210
|
-
* The coaches a block pays for its expected sailors: Σ (each program's sailors ÷ its sailors per
|
|
211
|
-
* coach), rounded up, but never more than it's given. Salaried coaches on salary are always paid, so
|
|
212
|
-
* only the others are cut, each staff row by the same share. When a program has no sailors per
|
|
213
|
-
* coach, the block pays all it's given.
|
|
214
|
-
*/
|
|
215
|
-
coachesExpected(block, programs) {
|
|
216
|
-
const given = sum(block.staff.map((s) => s.count));
|
|
217
|
-
const salaried = sum(block.staff.filter((s) => s.position_key != null && s.pay !== "hourly").map((s) => s.count));
|
|
218
|
-
let load = 0;
|
|
219
|
-
for (const o of new Map(block.members.map((a) => [a.offering.id, a.offering])).values()) {
|
|
220
|
-
const perCoach = programs.get(o.product_id)?.sailors_per_coach;
|
|
221
|
-
if (perCoach == null || perCoach <= 0)
|
|
222
|
-
return { coaches: given, share: 1 };
|
|
223
|
-
load += (this.ctx.enrollment.get(o.id) ?? 0) / perCoach;
|
|
224
|
-
}
|
|
225
|
-
const others = given - salaried;
|
|
226
|
-
const kept = Math.min(others, Math.max(0, ceil(load) - salaried));
|
|
227
|
-
return { coaches: salaried + kept, share: others > 0 ? kept / others : 1 };
|
|
228
|
-
}
|
|
229
205
|
/** Books a cost on a block, split among its activities by their offerings' sailors. */
|
|
230
206
|
book(block, account, amount, line, name, explanation, mtdcShare = 1, wages = false) {
|
|
231
207
|
if (amount === 0 || account === undefined)
|
|
@@ -255,11 +231,7 @@ export class Staffing {
|
|
|
255
231
|
const rates = new Map(ctx.data.plan_boat_rates.map((r) => [r.boat_class_id, r]));
|
|
256
232
|
const classes = new Map(ctx.data.boat_classes.map((c) => [c.id, c]));
|
|
257
233
|
for (const list of groupBy(blocks, (b) => b.row.date).values()) {
|
|
258
|
-
const chartered = list.flatMap((block) => block.boats.flatMap((b) => {
|
|
259
|
-
// The boats the expected sailors use, as for its own boats.
|
|
260
|
-
const n = ceil((b.chartered ?? 0) * block.fill);
|
|
261
|
-
return n > 0 ? [{ block, id: b.boat_class_id, n }] : [];
|
|
262
|
-
}));
|
|
234
|
+
const chartered = list.flatMap((block) => block.boats.flatMap((b) => ((b.chartered ?? 0) > 0 ? [{ block, id: b.boat_class_id, n: b.chartered }] : [])));
|
|
263
235
|
for (const [id, uses] of groupBy(chartered, (c) => c.id)) {
|
|
264
236
|
const name = classes.get(id)?.name ?? id;
|
|
265
237
|
const rate = rates.get(id)?.charter_per_day_cents;
|
|
@@ -366,13 +338,10 @@ export class Staffing {
|
|
|
366
338
|
const boatClasses = new Map(ctx.data.boat_classes.map((c) => [c.id, c]));
|
|
367
339
|
const boatRates = new Map(ctx.data.plan_boat_rates.map((r) => [r.boat_class_id, r]));
|
|
368
340
|
const given = new Map();
|
|
369
|
-
const tripCoaches = new Map();
|
|
370
341
|
for (const block of byDate(blocks)) {
|
|
371
342
|
const scope = { plan_id: block.row.plan_id, date: block.row.date };
|
|
372
343
|
const paidHours = block.paid_minutes / 60;
|
|
373
344
|
const waterHours = block.on_water_minutes / 60;
|
|
374
|
-
const expected = this.coachesExpected(block, programs);
|
|
375
|
-
tripCoaches.set(block.row.id, expected.coaches);
|
|
376
345
|
const book = (type, amount, line, name, explanation) => this.book(block, ctx.costAccount(type), amount, line, name, explanation, 1, type === "coach_wages");
|
|
377
346
|
let wages = 0;
|
|
378
347
|
for (const s of block.staff) {
|
|
@@ -381,7 +350,7 @@ export class Staffing {
|
|
|
381
350
|
if (s.position_key != null) {
|
|
382
351
|
const position = positions.get(s.position_key);
|
|
383
352
|
if (s.pay === "hourly") {
|
|
384
|
-
const hours = s.count *
|
|
353
|
+
const hours = s.count * paidHours;
|
|
385
354
|
const rate = position?.hourly_coaching_rate_cents;
|
|
386
355
|
if (position !== undefined && rate == null) {
|
|
387
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);
|
|
@@ -417,18 +386,16 @@ export class Staffing {
|
|
|
417
386
|
continue;
|
|
418
387
|
}
|
|
419
388
|
if (level.pay_type !== "volunteer") {
|
|
420
|
-
wages += s.count *
|
|
389
|
+
wages += s.count * paidHours * (level.hourly_rate_cents ?? 0);
|
|
421
390
|
}
|
|
422
391
|
}
|
|
423
|
-
book("coach_wages", wages, "coach_wages", "Coach wages", "
|
|
392
|
+
book("coach_wages", wages, "coach_wages", "Coach wages", "Staff × paid hours × each one's hourly rate");
|
|
424
393
|
for (const b of block.boats) {
|
|
425
394
|
const rate = boatRates.get(b.boat_class_id);
|
|
426
395
|
if (rate === undefined || b.count <= 0 || waterHours <= 0)
|
|
427
396
|
continue;
|
|
428
397
|
const boatClass = boatClasses.get(b.boat_class_id);
|
|
429
|
-
this.book(block, boatClass?.account_id ?? ctx.costAccount("boat_maintenance"),
|
|
430
|
-
waterHours *
|
|
431
|
-
(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");
|
|
432
399
|
}
|
|
433
400
|
const coaches = sum(block.staff.map((s) => s.count));
|
|
434
401
|
const boats = sum(block.boats.map((b) => b.count + (b.chartered ?? 0)));
|
|
@@ -437,7 +404,7 @@ export class Staffing {
|
|
|
437
404
|
}
|
|
438
405
|
}
|
|
439
406
|
this.bookCharters(byDate(blocks));
|
|
440
|
-
this.bookTrips(blocks, scheduled
|
|
407
|
+
this.bookTrips(blocks, scheduled);
|
|
441
408
|
return {
|
|
442
409
|
coaching,
|
|
443
410
|
doubleBooked,
|
|
@@ -459,10 +426,10 @@ export class Staffing {
|
|
|
459
426
|
};
|
|
460
427
|
}
|
|
461
428
|
/**
|
|
462
|
-
* A trip's coaches each fly and stay; the miles are driven once. Its coaches are the
|
|
463
|
-
*
|
|
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.
|
|
464
431
|
*/
|
|
465
|
-
bookTrips(blocks, scheduled
|
|
432
|
+
bookTrips(blocks, scheduled) {
|
|
466
433
|
const { ctx } = this;
|
|
467
434
|
for (const trip of ctx.data.plan_trips) {
|
|
468
435
|
const offering = ctx.offeringsById.get(trip.offering_id);
|
|
@@ -470,9 +437,8 @@ export class Staffing {
|
|
|
470
437
|
continue;
|
|
471
438
|
const travel = (a) => a.kind === "travel" && a.trip?.id === trip.id;
|
|
472
439
|
const staffed = [...blocks.values()].filter((b) => b.members.some(travel));
|
|
473
|
-
const coaches = Math.max(0, ...staffed.map((b) =>
|
|
474
|
-
|
|
475
|
-
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)) {
|
|
476
442
|
const first = scheduled
|
|
477
443
|
.filter(travel)
|
|
478
444
|
.map((a) => a.date)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cyc-seattle/platform",
|
|
3
|
-
"version": "1.55.
|
|
3
|
+
"version": "1.55.1",
|
|
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",
|