@cyc-seattle/platform 1.35.0 → 1.37.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.
@@ -1,4 +1,3 @@
1
- import { isoWeekday, within } from "./dates.js";
2
1
  /** Whole kids: `target_enrollment` if set, otherwise `capacity × fill_rate` rounded. */
3
2
  export function enrollmentOf(offering) {
4
3
  if (offering.target_enrollment != null)
@@ -11,33 +10,29 @@ function sum(values) {
11
10
  total += v;
12
11
  return total;
13
12
  }
14
- /** Coaches for `enrollment` sailors: at least `min_coaches`, and one per `sailors_per_coach`. */
15
- function coachesFor(offering, enrollment) {
16
- const ratio = offering.sailors_per_coach;
17
- const byRatio = ratio != null && ratio > 0 ? Math.ceil(enrollment / ratio) : 0;
18
- return Math.max(offering.min_coaches ?? 0, byRatio);
13
+ function groupBy(rows, key) {
14
+ const groups = new Map();
15
+ for (const row of rows)
16
+ groups.set(key(row), [...(groups.get(key(row)) ?? []), row]);
17
+ return groups;
19
18
  }
20
19
  export class Staffing {
21
20
  ctx;
22
- boatClasses;
23
- offeringBoatClasses = new Map();
24
21
  covers = new Map();
25
22
  warned = new Set();
26
23
  constructor(ctx) {
27
24
  this.ctx = ctx;
28
- this.boatClasses = new Map(ctx.data.boat_classes.map((b) => [b.id, b]));
29
- for (const row of ctx.data.plan_offering_boat_classes) {
30
- this.offeringBoatClasses.set(row.offering_id, [...(this.offeringBoatClasses.get(row.offering_id) ?? []), row]);
31
- }
32
25
  for (const c of ctx.data.qualification_covers) {
33
26
  this.covers.set(c.qualification_id, new Set([...(this.covers.get(c.qualification_id) ?? []), c.covers_id]));
34
27
  }
35
28
  }
36
- warnOnce(code, message, key) {
37
- if (this.warned.has(`${code}|${key}`))
29
+ /** Warns once for each row and plan copy, about the first date it's seen on. */
30
+ warnOnce(code, message, key, scope = {}) {
31
+ const id = `${code}|${key}|${scope.version_id ?? ""}`;
32
+ if (this.warned.has(id))
38
33
  return;
39
- this.warned.add(`${code}|${key}`);
40
- this.ctx.warn(code, message, key);
34
+ this.warned.add(id);
35
+ this.ctx.warn(code, message, key, scope);
41
36
  }
42
37
  /** Whether holding `held` qualifies for `required`, through `qualification_covers`. */
43
38
  qualifies(held, required) {
@@ -54,383 +49,378 @@ export class Staffing {
54
49
  }
55
50
  return false;
56
51
  }
57
- levelFor(offering, block) {
58
- const levels = this.ctx.data.plan_staff_levels;
59
- const blockLevel = block?.coach_level_id == null ? undefined : levels.find((l) => l.id === block.coach_level_id);
60
- if (blockLevel !== undefined)
61
- return blockLevel;
62
- if (offering.coach_level_key == null)
63
- return undefined;
64
- return levels.find((l) => l.key === offering.coach_level_key);
65
- }
66
- sailorsByClass(offering, enrollment) {
67
- const onClubBoats = enrollment * (1 - (offering.byob_share ?? 0));
68
- const classes = this.offeringBoatClasses.get(offering.id) ?? [];
69
- if (classes.length === 0)
70
- return new Map([["", onClubBoats]]);
71
- return new Map(classes.map((c) => [c.boat_class_id, onClubBoats * c.share]));
72
- }
73
- boatsFor(sailors, offeringKey) {
74
- const boats = new Map();
75
- for (const [classId, n] of sailors) {
76
- const perBoat = this.boatClasses.get(classId)?.sailors_per_boat;
77
- if (perBoat == null || perBoat <= 0) {
78
- if (n > 0) {
79
- this.warnOnce("no_sailors_per_boat", classId === ""
80
- ? `Offering ${offeringKey} sails boats but names no boat class`
81
- : `Boat class ${classId} has no sailors_per_boat, so offering ${offeringKey} counts no boats in it`, offeringKey);
82
- }
52
+ /**
53
+ * Gathers each block's activities, times and allocations. A block's paid time runs from its first
54
+ * activity's start to its last one's end, plus the longest setup and put-away. Its time on the
55
+ * water is the longest `on_water_minutes` its activities set, or else the span of those that sail.
56
+ */
57
+ blocks(scheduled) {
58
+ const { data } = this.ctx;
59
+ const staff = groupBy(data.plan_block_staff, (r) => r.block_id);
60
+ const resources = groupBy(data.plan_block_resources, (r) => r.block_id);
61
+ const boats = groupBy(data.plan_block_boats, (r) => r.block_id);
62
+ const rows = new Map(data.plan_activity_blocks.map((b) => [b.id, b]));
63
+ const members = new Map();
64
+ for (const a of scheduled) {
65
+ const id = a.stored.block_id;
66
+ if (id == null)
67
+ continue;
68
+ const row = rows.get(id);
69
+ if (row === undefined) {
70
+ this.ctx.warn("unknown_block", `Activity ${a.stored.key} names a missing block`, a.stored.key);
83
71
  continue;
84
72
  }
85
- boats.set(classId, Math.ceil(n / perBoat));
73
+ if (row.date !== a.date) {
74
+ this.ctx.warn("block_date_mismatch", `${a.offering.name}'s activity on ${a.date} is in ${row.name}, which is on ${row.date}`, a.stored.key, { version_id: a.stored.version_id, date: a.date });
75
+ }
76
+ members.set(id, [...(members.get(id) ?? []), a]);
86
77
  }
87
- return boats;
78
+ const result = new Map();
79
+ for (const [id, list] of members) {
80
+ const row = rows.get(id);
81
+ const span = timeSpan(list);
82
+ const sailing = list.filter((a) => a.uses_boats);
83
+ const set = sailing.flatMap((a) => (a.stored.on_water_minutes == null ? [] : [a.stored.on_water_minutes]));
84
+ const sailingSpan = timeSpan(sailing);
85
+ const sailingMinutes = sailingSpan.start === null || sailingSpan.end === null
86
+ ? Math.max(0, ...sailing.map((a) => a.on_water_minutes))
87
+ : sailingSpan.end - sailingSpan.start;
88
+ // Each offering's sailors count once, split among its activities in the block.
89
+ const enrollment = (a) => this.ctx.enrollment.get(a.offering.id) ?? 0;
90
+ const byOffering = groupBy(list, (a) => a.offering.id);
91
+ const total = sum([...byOffering.values()].map((own) => enrollment(own[0])));
92
+ const share = (a) => (total > 0 ? enrollment(a) / total : 1 / byOffering.size) / byOffering.get(a.offering.id).length;
93
+ result.set(id, {
94
+ row,
95
+ members: list,
96
+ ...span,
97
+ paid_minutes: (span.start === null || span.end === null ? 0 : span.end - span.start) +
98
+ Math.max(0, ...list.map((a) => a.stored.setup_minutes ?? 0)) +
99
+ Math.max(0, ...list.map((a) => a.stored.putaway_minutes ?? 0)),
100
+ on_water_minutes: set.length > 0 ? Math.max(...set) : sailingMinutes,
101
+ staff: staff.get(id) ?? [],
102
+ resources: resources.get(id) ?? [],
103
+ boats: boats.get(id) ?? [],
104
+ shares: new Map(list.map((a) => [a, share(a)])),
105
+ });
106
+ }
107
+ return result;
88
108
  }
89
- initial(a) {
90
- const { offering, stored } = a;
91
- const enrollment = this.ctx.enrollment.get(offering.id) ?? 0;
92
- const fixedCoaches = stored.fixed_coaches ?? offering.fixed_coaches;
93
- const fixedRibs = stored.fixed_ribs ?? offering.fixed_ribs;
94
- const fixedBoats = stored.fixed_boats ?? offering.fixed_boats;
95
- const coaches = fixedCoaches ?? coachesFor(offering, enrollment);
96
- const travel = a.kind === "travel";
97
- const ribs = fixedRibs ?? (travel ? 0 : Math.ceil(coaches * (offering.ribs_per_coach ?? 0)));
98
- const sailors = a.uses_boats ? this.sailorsByClass(offering, enrollment) : new Map();
99
- let boats;
100
- if (!a.uses_boats)
101
- boats = new Map();
102
- else if (fixedBoats != null) {
103
- const total = sum(sailors.values());
104
- boats = new Map([...sailors].map(([c, n]) => [c, total > 0 ? (fixedBoats * n) / total : fixedBoats / sailors.size]));
109
+ /** Warns about each activity, other than travel, that's in no block, or in one with no staff. */
110
+ checkUnallocated(scheduled, blocks) {
111
+ for (const a of scheduled) {
112
+ if (a.kind === "travel")
113
+ continue;
114
+ const block = a.stored.block_id == null ? undefined : blocks.get(a.stored.block_id);
115
+ if (block !== undefined && sum(block.staff.map((s) => s.count)) > 0)
116
+ continue;
117
+ this.ctx.warn("unallocated_activity", `${a.offering.name}'s ${a.kind} on ${a.date} has no staff`, a.stored.key, {
118
+ version_id: a.stored.version_id,
119
+ date: a.date,
120
+ });
105
121
  }
106
- else
107
- boats = this.boatsFor(sailors, offering.key);
108
- return {
109
- a,
110
- enrollment,
111
- coaches,
112
- ribs,
113
- boats,
114
- sailors,
115
- fixed: { coaches: fixedCoaches != null, ribs: fixedRibs != null, boats: fixedBoats != null },
116
- salaried: 0,
117
- positions: [],
118
- hourlyPositions: [],
119
- level: undefined,
120
- block: null,
121
- group: null,
122
- };
123
122
  }
124
- blockFor(a) {
125
- if (a.kind === "travel")
126
- return null;
127
- return (this.ctx.data.plan_activity_block_rules.find((b) => b.offering_keys.includes(a.offering.key) &&
128
- within(a.date, b.starts_on, b.ends_on) &&
129
- (b.weekdays == null || b.weekdays.length === 0 || b.weekdays.includes(isoWeekday(a.date)))) ?? null);
123
+ /** Each program's settings, by its product. */
124
+ programs() {
125
+ return new Map(this.ctx.data.plan_programs.map((p) => [p.product_id, p]));
126
+ }
127
+ /** Whether a staff member holding `held`, at level `levelKey` if any, may coach a program coached at `required`. */
128
+ qualifiedFor(held, levelKey, required) {
129
+ if (levelKey === required)
130
+ return true;
131
+ const level = this.ctx.data.plan_staff_levels.find((l) => l.key === required);
132
+ const needed = level?.qualifications ?? [];
133
+ return needed.length > 0 && needed.every((q) => this.qualifies(held, q));
130
134
  }
131
135
  /**
132
- * Staffs a block's activities on one date as one group, then gives each activity its share. A
133
- * count an activity fixes keeps it out of that part of the group.
136
+ * Warns when a block's sailors are more than its staff or its boats can safely take. Each program's
137
+ * sailors count against its own sailors per coach, and a coach counts if they're at the staff
138
+ * level of one of the block's programs, or qualified for it. Boats count the ones sailors bring.
134
139
  */
135
- staffBlock(block, group) {
136
- for (const s of group)
137
- s.block = block;
138
- const byEnrollment = (members, total, s) => sum(members.map((m) => m.enrollment)) > 0
139
- ? (total * s.enrollment) / sum(members.map((m) => m.enrollment))
140
- : total / members.length;
141
- const coached = group.filter((s) => !s.fixed.coaches);
142
- let coachTotal = 0;
143
- if (coached.length > 0) {
144
- const load = sum(coached.map((s) => {
145
- const ratio = s.a.offering.sailors_per_coach;
146
- return ratio != null && ratio > 0 ? s.enrollment / ratio : 0;
147
- }));
148
- coachTotal = Math.max(Math.ceil(load - 1e-9), ...coached.map((s) => s.a.offering.min_coaches ?? 0));
149
- for (const s of coached)
150
- s.coaches = byEnrollment(coached, coachTotal, s);
151
- }
152
- const ribbed = group.filter((s) => !s.fixed.ribs);
153
- if (ribbed.length > 0) {
154
- const coaches = sum(ribbed.map((s) => s.coaches));
155
- const ratio = Math.max(...ribbed.map((s) => s.a.offering.ribs_per_coach ?? 0));
156
- const ribTotal = Math.ceil(coaches * ratio - 1e-9);
157
- for (const s of ribbed)
158
- s.ribs = coaches > 0 ? (ribTotal * s.coaches) / coaches : 0;
159
- }
160
- const boated = group.filter((s) => !s.fixed.boats && s.a.uses_boats);
161
- const sailorsByClass = new Map();
162
- for (const s of boated) {
163
- for (const [c, n] of s.sailors)
164
- sailorsByClass.set(c, (sailorsByClass.get(c) ?? 0) + n);
140
+ checkCapacity(block, programs) {
141
+ const { ctx } = this;
142
+ const offerings = [
143
+ ...new Map(block.members.filter((a) => a.kind !== "travel").map((a) => [a.offering.id, a.offering])).values(),
144
+ ];
145
+ const sailors = sum(offerings.map((o) => ctx.enrollment.get(o.id) ?? 0));
146
+ if (sailors <= 0)
147
+ return;
148
+ const scope = { version_id: block.row.version_id, date: block.row.date };
149
+ const where = `${block.row.date}, ${block.row.name}`;
150
+ let load = 0;
151
+ let counted = 0;
152
+ const required = [];
153
+ for (const o of offerings) {
154
+ const settings = programs.get(o.product_id);
155
+ if (settings?.staff_level_key == null || settings.sailors_per_coach == null || settings.sailors_per_coach <= 0) {
156
+ this.warnOnce("no_program_settings", `${o.name}'s program has no staff level or sailors per coach, so its sailors aren't checked against its staff`, o.product_id, { version_id: o.version_id, date: block.row.date });
157
+ continue;
158
+ }
159
+ const n = ctx.enrollment.get(o.id) ?? 0;
160
+ load += n / settings.sailors_per_coach;
161
+ counted += n;
162
+ required.push(settings.staff_level_key);
165
163
  }
166
- const shared = this.boatsFor(sailorsByClass, group[0]?.a.offering.key ?? "");
167
- for (const s of boated) {
168
- s.boats = new Map([...s.sailors].map(([c, n]) => {
169
- const totalSailors = sailorsByClass.get(c) ?? 0;
170
- return [c, totalSailors > 0 ? ((shared.get(c) ?? 0) * n) / totalSailors : 0];
164
+ if (counted > 0) {
165
+ const levels = new Map(ctx.data.plan_staff_levels.map((l) => [l.key, l]));
166
+ const positions = new Map(ctx.data.plan_salaried_positions.map((p) => [p.key, p]));
167
+ const qualified = sum(block.staff.map((s) => {
168
+ const held = s.position_key != null
169
+ ? (positions.get(s.position_key)?.qualifications ?? [])
170
+ : (levels.get(s.staff_level_key ?? "")?.qualifications ?? []);
171
+ const level = s.position_key != null ? null : s.staff_level_key;
172
+ return required.some((r) => this.qualifiedFor(held, level, r)) ? s.count : 0;
171
173
  }));
172
- }
173
- // The block's salaried positions, and its offerings' named coaches, take its first coaching
174
- // slots. Those paid out of a salary mean fewer hourly coaches are paid.
175
- // An offering with a fixed coach count keeps out of the shared coaches, so its coach does too.
176
- for (const s of group)
177
- if (s.fixed.coaches)
178
- this.staffNamedCoach(s);
179
- const named = (pay) => coached.flatMap((s) => s.a.offering.coach_position_key != null && payOf(s.a.offering) === pay ? [s.a.offering.coach_position_key] : []);
180
- const positions = [...new Set([...(block.salaried_positions ?? []), ...named("salary")])];
181
- const hourlyPositions = [...new Set(named("hourly"))].filter((key) => !positions.includes(key));
182
- if (coachTotal > 0) {
183
- const salariedTotal = Math.min(positions.length, coachTotal);
184
- const hourlyTotal = Math.min(hourlyPositions.length, coachTotal - salariedTotal);
185
- for (const s of coached) {
186
- s.salaried = (salariedTotal * s.coaches) / coachTotal;
187
- s.positions =
188
- salariedTotal > 0 ? positions.map((key) => ({ key, coaches: s.salaried / positions.length })) : [];
189
- const hourly = (hourlyTotal * s.coaches) / coachTotal;
190
- s.hourlyPositions =
191
- hourlyTotal > 0 ? hourlyPositions.map((key) => ({ key, coaches: hourly / hourlyPositions.length })) : [];
174
+ if (load > qualified + 1e-9) {
175
+ ctx.warn("over_capacity", `${where}: ${counted} sailors, but its staff can take ${Math.floor((qualified * counted) / load + 1e-9)}`, block.row.key, scope);
192
176
  }
193
177
  }
194
- }
195
- /** An offering's named coach, on an activity outside any block: one of its coaches. */
196
- staffNamedCoach(s) {
197
- const key = s.a.offering.coach_position_key;
198
- if (key == null || s.a.kind === "travel" || s.coaches <= 0)
178
+ if (!block.members.some((a) => a.uses_boats))
199
179
  return;
200
- const coaches = Math.min(1, s.coaches);
201
- if (payOf(s.a.offering) === "salary") {
202
- s.salaried = coaches;
203
- s.positions = [{ key, coaches }];
180
+ const classes = new Map(ctx.data.boat_classes.map((c) => [c.id, c]));
181
+ let seats = 0;
182
+ for (const b of block.boats) {
183
+ const perBoat = classes.get(b.boat_class_id)?.sailors_per_boat;
184
+ if (perBoat == null || perBoat <= 0) {
185
+ this.warnOnce("no_sailors_per_boat", `Boat class ${classes.get(b.boat_class_id)?.name ?? b.boat_class_id} has no sailors per boat, so its boats take no sailors`, b.boat_class_id, scope);
186
+ continue;
187
+ }
188
+ seats += (b.count + (b.sailor_owned ?? 0)) * perBoat;
204
189
  }
205
- else {
206
- s.hourlyPositions = [{ key, coaches }];
190
+ if (sailors > seats) {
191
+ ctx.warn("over_capacity", `${where}: ${sailors} sailors, but its boats can take ${seats}`, block.row.key, scope);
207
192
  }
208
193
  }
209
194
  /**
210
- * Splits a block's activities on one date into groups whose times overlap. An activity with no
211
- * times overlaps every other.
195
+ * Warns when the organization's boats of one class, or a resource such as RIBs, are given to more
196
+ * blocks at once on a date than it has. Boats sailors bring don't count.
212
197
  */
213
- overlapping(members) {
214
- if (members.some((s) => s.a.start_minutes === null || s.a.end_minutes === null))
215
- return [members];
216
- const sorted = [...members].sort((x, y) => x.a.start_minutes - y.a.start_minutes);
217
- const groups = [];
218
- let end = -Infinity;
219
- for (const s of sorted) {
220
- const last = groups.at(-1);
221
- if (last !== undefined && s.a.start_minutes < end) {
222
- last.push(s);
223
- end = Math.max(end, s.a.end_minutes);
198
+ checkInventory(blocks) {
199
+ const { ctx } = this;
200
+ const dates = groupBy(blocks, (b) => b.row.date);
201
+ const classes = new Map(ctx.data.boat_classes.map((c) => [c.id, c]));
202
+ const fleet = new Map(ctx.data.plan_boat_rates.map((r) => [r.boat_class_id, r.quantity]));
203
+ const resources = new Map(ctx.data.plan_resources.map((r) => [r.key, r]));
204
+ for (const [date, list] of dates) {
205
+ const check = (code, what, have, used) => {
206
+ if (have == null)
207
+ return;
208
+ const out = list.flatMap((b) => (used(b) > 0 ? [{ block: b, n: used(b) }] : []));
209
+ const peak = mostAtOnce(out);
210
+ if (peak.total <= have)
211
+ return;
212
+ const names = peak.at.map(({ block, n }) => `${block.row.name} ${n}`).join(", ");
213
+ for (const version of new Set(peak.at.map(({ block }) => block.row.version_id))) {
214
+ ctx.warn(code, `${date}: ${peak.total} ${what} out, but the organization has ${have} (${names})`, what, {
215
+ version_id: version,
216
+ date: date,
217
+ });
218
+ }
219
+ };
220
+ const classIds = new Set(list.flatMap((b) => b.boats.filter((x) => x.count > 0).map((x) => x.boat_class_id)));
221
+ for (const id of classIds) {
222
+ if (fleet.get(id) == null) {
223
+ for (const b of list.filter((x) => x.boats.some((y) => y.boat_class_id === id && y.count > 0))) {
224
+ this.warnOnce("no_fleet_quantity", `The Operations Plan doesn't say how many ${classes.get(id)?.name ?? id} the organization has, so they aren't checked`, id, { version_id: b.row.version_id, date });
225
+ }
226
+ }
227
+ check("over_inventory_boats", classes.get(id)?.name ?? id, fleet.get(id), (b) => sum(b.boats.filter((x) => x.boat_class_id === id).map((x) => x.count)));
224
228
  }
225
- else {
226
- groups.push([s]);
227
- end = s.a.end_minutes;
229
+ const resourceKeys = new Set(list.flatMap((b) => b.resources.map((x) => x.resource_key)));
230
+ for (const key of resourceKeys) {
231
+ const resource = resources.get(key);
232
+ check("over_inventory_resource", resource?.name ?? key, resource?.quantity, (b) => sum(b.resources.filter((x) => x.resource_key === key).map((x) => x.quantity)));
228
233
  }
229
234
  }
230
- return groups;
231
235
  }
232
236
  /**
233
- * Warns when a salaried position coaches two activities at once that aren't staffed as one block
234
- * group. It's still charged to both. Returns each position's double-booked hours and dates.
237
+ * Warns when a salaried position coaches two blocks whose times overlap. It's still charged to
238
+ * both. Returns each position's double-booked hours and dates.
235
239
  */
236
- checkDoubleBooked(staffed) {
240
+ checkDoubleBooked(blocks) {
237
241
  const byDay = new Map();
238
- for (const s of staffed) {
239
- for (const p of [...s.positions, ...s.hourlyPositions]) {
240
- if (p.coaches <= 0)
242
+ for (const b of blocks) {
243
+ for (const s of b.staff) {
244
+ if (s.position_key == null || s.count <= 0)
241
245
  continue;
242
- const id = `${p.key}|${s.a.date}`;
243
- byDay.set(id, [...(byDay.get(id) ?? []), s]);
246
+ const id = `${s.position_key}|${b.row.date}`;
247
+ byDay.set(id, [...(byDay.get(id) ?? []), b]);
244
248
  }
245
249
  }
246
250
  const names = new Map(this.ctx.data.plan_salaried_positions.map((p) => [p.key, p.name]));
247
251
  const result = new Map();
248
252
  for (const [id, list] of byDay) {
249
253
  const [key, date] = id.split("|");
250
- const clash = list.flatMap((x, i) => list
251
- .slice(i + 1)
252
- .flatMap((y) => ((x.group !== null && x.group === y.group) || !overlaps(x, y) ? [] : [[x, y]])))[0];
254
+ const clash = list.flatMap((x, i) => list.slice(i + 1).flatMap((y) => (overlaps(x, y) ? [[x, y]] : [])))[0];
253
255
  if (clash === undefined)
254
256
  continue;
255
257
  const hours = extraCoachMinutes(list) / 60;
256
- const sum = result.get(key) ?? { hours: 0, dates: 0 };
257
- result.set(key, { hours: sum.hours + hours, dates: sum.dates + 1 });
258
- this.ctx.warn("position_double_booked", `${names.get(key) ?? key} coaches ${clash[0].a.offering.name} and ${clash[1].a.offering.name} at the same time on ${date}` +
259
- (hours > 0 ? `, so another coach is needed for ${round2(hours)} hours` : ""), key);
258
+ const total = result.get(key) ?? { hours: 0, dates: 0 };
259
+ result.set(key, { hours: total.hours + hours, dates: total.dates + 1 });
260
+ for (const version of new Set(list.map((b) => b.row.version_id))) {
261
+ this.ctx.warn("position_double_booked", `${names.get(key) ?? key} coaches ${clash[0].row.name} and ${clash[1].row.name} at the same time on ${date}` +
262
+ (hours > 0 ? `, so another coach is needed for ${round2(hours)} hours` : ""), key, { version_id: version, date });
263
+ }
260
264
  }
261
265
  return result;
262
266
  }
263
- /**
264
- * One RIB's hourly and daily cost, averaged over the RIB resources by `quantity`, and the part of
265
- * it from resources excluded from MTDC, such as rentals.
266
- */
267
- ribRate() {
268
- const kind = this.ctx.data.rib_resource_kind_id;
269
- if (kind == null)
270
- return undefined;
271
- const ribs = this.ctx.data.plan_resources.filter((r) => r.resource_kind_id === kind);
272
- if (ribs.length === 0)
273
- return undefined;
274
- const weight = (r) => r.quantity ?? 1;
275
- const totalWeight = sum(ribs.map(weight));
276
- const average = (rows, cost) => sum(rows.map((r) => weight(r) * cost(r))) / totalWeight;
277
- const rentals = ribs.filter((r) => r.excluded_from_mtdc);
278
- return {
279
- hourly: average(ribs, hourlyCost),
280
- daily: average(ribs, (r) => r.cost_per_day_cents ?? 0),
281
- hourlyRental: average(rentals, hourlyCost),
282
- rental: average(rentals, (r) => r.cost_per_day_cents ?? 0),
283
- };
284
- }
285
- /** Staffs every activity, and records what its coaches, RIBs, boats and resources cost. */
267
+ /** Costs every block's staff, RIBs, boats and resources, and checks them against the limits. */
286
268
  run(scheduled) {
287
269
  const { ctx } = this;
288
- const staffed = scheduled.map((a) => this.initial(a));
289
- const days = new Map();
290
- for (const s of staffed) {
291
- const block = this.blockFor(s.a);
292
- if (block === null) {
293
- this.staffNamedCoach(s);
294
- continue;
295
- }
296
- const id = `${block.id}|${s.a.date}`;
297
- const day = days.get(id) ?? { block, members: [] };
298
- day.members.push(s);
299
- days.set(id, day);
300
- }
301
- for (const [id, { block, members }] of days) {
302
- this.overlapping(members).forEach((group, i) => {
303
- for (const s of group)
304
- s.group = `${id}|${i}`;
305
- this.staffBlock(block, group);
306
- });
307
- }
308
- const doubleBooked = this.checkDoubleBooked(staffed);
270
+ const blocks = this.blocks(scheduled);
271
+ this.checkUnallocated(scheduled, blocks);
272
+ const programs = this.programs();
273
+ // By date, so a warning given once names the first date it's seen on.
274
+ for (const block of byDate(blocks))
275
+ this.checkCapacity(block, programs);
276
+ this.checkInventory(byDate(blocks));
277
+ const doubleBooked = this.checkDoubleBooked([...blocks.values()]);
309
278
  const coaching = [];
310
- const resourcesByActivity = new Map();
311
- for (const r of ctx.data.plan_activity_resources) {
312
- resourcesByActivity.set(r.activity_id, [...(resourcesByActivity.get(r.activity_id) ?? []), r]);
313
- }
279
+ const levels = new Map(ctx.data.plan_staff_levels.map((l) => [l.key, l]));
280
+ const positions = new Map(ctx.data.plan_salaried_positions.map((p) => [p.key, p]));
314
281
  const resources = new Map(ctx.data.plan_resources.map((r) => [r.key, r]));
315
282
  const boatClasses = new Map(ctx.data.boat_classes.map((c) => [c.id, c]));
316
- const salaried = new Map(ctx.data.plan_salaried_positions.map((p) => [p.key, p]));
317
- const ribRate = this.ribRate();
318
- for (const s of staffed) {
319
- const { a } = s;
320
- const offering = a.offering;
321
- const key = offering.key;
322
- const paidHours = a.paid_minutes / 60;
323
- const waterHours = a.on_water_minutes / 60;
283
+ const boatRates = new Map(ctx.data.plan_boat_rates.map((r) => [r.boat_class_id, r]));
284
+ const ribKind = ctx.data.rib_resource_kind_id;
285
+ const given = new Map();
286
+ for (const block of byDate(blocks)) {
287
+ const scope = { version_id: block.row.version_id, date: block.row.date };
288
+ const paidHours = block.paid_minutes / 60;
289
+ const waterHours = block.on_water_minutes / 60;
290
+ // Each cost is split among the block's activities by their offerings' sailors.
324
291
  const book = (type, amount, line, name, explanation, mtdcShare = 1) => {
325
292
  if (amount === 0)
326
293
  return;
327
294
  const account = ctx.costAccount(type);
328
295
  if (account === undefined)
329
296
  return;
330
- ctx.book({
331
- cost_center_type: "offering",
332
- cost_center_key: key,
333
- account_id: account,
334
- function: ctx.functionFor(account, null, "offering"),
335
- amount,
336
- line_key: `${line}:${key}`,
337
- line_name: name,
338
- explanation,
339
- mtdc_share: mtdcShare,
340
- wages: type === "coach_wages",
341
- });
342
- };
343
- s.level = this.levelFor(offering, s.block);
344
- const hourly = s.coaches - s.salaried;
345
- if (offering.required_qualification != null && s.level !== undefined) {
346
- if (!this.qualifies(s.level.qualifications ?? [], offering.required_qualification)) {
347
- this.warnOnce("unqualified_level", `Offering ${key}'s coach level ${s.level.name} lacks its required qualification`, key);
348
- }
349
- }
350
- if (hourly > 0 && s.level === undefined) {
351
- this.warnOnce("no_coach_level", `Offering ${key} has coaches but no coach level, so they cost nothing`, key);
352
- }
353
- const volunteer = s.level?.pay_type === "volunteer";
354
- const rate = volunteer ? 0 : (s.level?.hourly_rate_cents ?? 0);
355
- // A salaried coach paid by the hour may have their own rate, even on a volunteer level.
356
- const rateOf = (p) => salaried.get(p.key)?.hourly_coaching_rate_cents ?? rate;
357
- const own = s.hourlyPositions.reduce((t, p) => t + p.coaches * (rateOf(p) - rate), 0);
358
- const wages = (hourly * rate + own) * paidHours;
359
- book("coach_wages", wages, "coach_wages", "Coach wages", "Hourly coaches × paid hours × each one's rate");
360
- for (const p of s.positions) {
361
- coaching.push({
362
- position_key: p.key,
363
- offering_key: key,
364
- hours: p.coaches * paidHours,
365
- pay: "salary",
366
- wages: 0,
367
- });
368
- }
369
- for (const p of s.hourlyPositions) {
370
- const hours = p.coaches * paidHours;
371
- coaching.push({ position_key: p.key, offering_key: key, hours, pay: "hourly", wages: hours * rateOf(p) });
372
- }
373
- if (s.ribs > 0 && waterHours > 0) {
374
- if (ribRate === undefined) {
375
- this.warnOnce("no_rib_rate", "No RIB resource is in the plan, so RIBs cost nothing", "ribs");
297
+ for (const [a, share] of block.shares) {
298
+ ctx.book({
299
+ cost_center_type: "offering",
300
+ cost_center_key: a.offering.key,
301
+ account_id: account,
302
+ function: ctx.functionFor(account, null, "offering"),
303
+ amount: amount * share,
304
+ line_key: `${line}:${a.offering.key}`,
305
+ line_name: name,
306
+ explanation,
307
+ mtdc_share: mtdcShare,
308
+ wages: type === "coach_wages",
309
+ });
376
310
  }
377
- else {
378
- const cost = s.ribs * (waterHours * ribRate.hourly + ribRate.daily);
379
- const rental = s.ribs * (waterHours * ribRate.hourlyRental + ribRate.rental);
380
- book("ribs", cost, "ribs", "RIBs", "RIBs × on-water hours × the RIB rate, plus a day rate", cost > 0 ? 1 - rental / cost : 1);
381
- }
382
- }
383
- for (const [classId, boats] of s.boats) {
384
- if (boats <= 0 || waterHours <= 0)
311
+ };
312
+ let wages = 0;
313
+ for (const s of block.staff) {
314
+ if (s.count <= 0)
385
315
  continue;
386
- const boatRate = ctx.data.plan_boat_rates.find((r) => r.boat_class_id === classId);
387
- if (boatRate === undefined)
316
+ if (s.position_key != null) {
317
+ const position = positions.get(s.position_key);
318
+ const hours = s.count * paidHours;
319
+ if (s.pay === "hourly") {
320
+ const rate = position?.hourly_coaching_rate_cents;
321
+ if (position !== undefined && rate == null) {
322
+ this.warnOnce("no_hourly_rate", `${position.name} is paid by the hour to coach but has no hourly rate, so those hours cost nothing`, position.key, scope);
323
+ }
324
+ wages += hours * (rate ?? 0);
325
+ for (const [a, share] of block.shares) {
326
+ coaching.push({
327
+ position_key: s.position_key,
328
+ offering_key: a.offering.key,
329
+ hours: hours * share,
330
+ pay: "hourly",
331
+ wages: hours * share * (rate ?? 0),
332
+ });
333
+ }
334
+ }
335
+ else {
336
+ for (const [a, share] of block.shares) {
337
+ coaching.push({
338
+ position_key: s.position_key,
339
+ offering_key: a.offering.key,
340
+ hours: hours * share,
341
+ pay: "salary",
342
+ wages: 0,
343
+ });
344
+ }
345
+ }
346
+ continue;
347
+ }
348
+ const level = s.staff_level_key == null ? undefined : levels.get(s.staff_level_key);
349
+ if (level === undefined) {
350
+ this.warnOnce("unknown_staff_level", `${block.row.name} has staff at a missing staff level, so they cost nothing`, s.staff_level_key ?? s.key, scope);
388
351
  continue;
389
- book("boat_maintenance", boats * waterHours * boatRate.maintenance_per_boat_hour_cents, `boats:${classId}`, `Boat maintenance: ${boatClasses.get(classId)?.name ?? "boats"}`, "Boats × on-water hours × the class's maintenance rate");
352
+ }
353
+ if (level.pay_type !== "volunteer")
354
+ wages += s.count * paidHours * (level.hourly_rate_cents ?? 0);
390
355
  }
391
- for (const use of resourcesByActivity.get(a.stored.id) ?? []) {
356
+ book("coach_wages", wages, "coach_wages", "Coach wages", "Staff × paid hours × each one's hourly rate");
357
+ let ribs = 0;
358
+ for (const use of block.resources) {
392
359
  const resource = resources.get(use.resource_key);
393
360
  if (resource === undefined) {
394
- this.warnOnce("unknown_resource", `Activity uses missing resource ${use.resource_key}`, use.resource_key);
361
+ this.warnOnce("unknown_resource", `${block.row.name} uses missing resource ${use.resource_key}`, use.resource_key, scope);
395
362
  continue;
396
363
  }
397
- const hours = use.hours ?? paidHours;
398
- book("resources", use.quantity * (hours * hourlyCost(resource) + (resource.cost_per_day_cents ?? 0)), `resource:${use.resource_key}`, resource.name, `${resource.name}: quantity × hours × hourly cost, plus a day rate`, resource.excluded_from_mtdc ? 0 : 1);
364
+ const rib = ribKind != null && resource.resource_kind_id === ribKind;
365
+ if (rib)
366
+ ribs += use.quantity;
367
+ const hours = use.hours ?? (rib ? waterHours : paidHours);
368
+ const cost = use.quantity * (hours * hourlyCost(resource) + (resource.cost_per_day_cents ?? 0));
369
+ book(rib ? "ribs" : "resources", cost, rib ? "ribs" : `resource:${resource.key}`, rib ? "RIBs" : resource.name, `${resource.name}: quantity × hours × hourly cost, plus a day rate`, resource.excluded_from_mtdc ? 0 : 1);
370
+ }
371
+ for (const b of block.boats) {
372
+ const rate = boatRates.get(b.boat_class_id);
373
+ if (rate === undefined || b.count <= 0 || waterHours <= 0)
374
+ continue;
375
+ book("boat_maintenance", b.count * waterHours * rate.maintenance_per_boat_hour_cents, `boats:${b.boat_class_id}`, `Boat maintenance: ${boatClasses.get(b.boat_class_id)?.name ?? "boats"}`, "The organization's boats × on-water hours × the class's maintenance rate");
376
+ }
377
+ const coaches = sum(block.staff.map((s) => s.count));
378
+ const boats = sum(block.boats.map((b) => b.count));
379
+ for (const [a, share] of block.shares) {
380
+ given.set(a, { coaches: coaches * share, ribs: ribs * share, boats: boats * share });
399
381
  }
400
382
  }
401
- this.bookTrips(staffed);
383
+ this.bookTrips(blocks, scheduled);
402
384
  return {
403
385
  coaching,
404
386
  doubleBooked,
405
- activities: staffed.map((s) => ({
406
- offering_id: s.a.offering.id,
407
- offering_key: s.a.offering.key,
408
- trip_id: s.a.trip?.id ?? null,
409
- activity_id: s.a.stored.id,
410
- date: s.a.date,
411
- kind: s.a.kind,
412
- paid_minutes: s.a.paid_minutes,
413
- on_water_minutes: s.a.on_water_minutes,
414
- uses_boats: s.a.uses_boats,
415
- coaches: s.coaches,
416
- ribs: s.ribs,
417
- boats: sum(s.boats.values()),
418
- block_rule_id: s.block?.id ?? null,
387
+ activities: scheduled.map((a) => ({
388
+ offering_id: a.offering.id,
389
+ offering_key: a.offering.key,
390
+ trip_id: a.trip?.id ?? null,
391
+ activity_id: a.stored.id,
392
+ date: a.date,
393
+ kind: a.kind,
394
+ paid_minutes: a.paid_minutes,
395
+ on_water_minutes: a.on_water_minutes,
396
+ uses_boats: a.uses_boats,
397
+ coaches: given.get(a)?.coaches ?? 0,
398
+ ribs: given.get(a)?.ribs ?? 0,
399
+ boats: given.get(a)?.boats ?? 0,
400
+ block_id: blocks.has(a.stored.block_id ?? "") ? a.stored.block_id : null,
419
401
  })),
420
402
  };
421
403
  }
422
404
  /**
423
- * A trip's coaches each fly and stay; the miles are driven once. Its coaches are those of its
424
- * travel days; with none, the offering's coaching ratio sets them.
405
+ * A trip's coaches each fly and stay; the miles are driven once. Its coaches are the staff on the
406
+ * block of its busiest travel day.
425
407
  */
426
- bookTrips(staffed) {
408
+ bookTrips(blocks, scheduled) {
427
409
  const { ctx } = this;
428
410
  for (const trip of ctx.data.plan_trips) {
429
411
  const offering = ctx.offeringsById.get(trip.offering_id);
430
412
  if (offering === undefined || !ctx.inYear(offering))
431
413
  continue;
432
- const travel = staffed.find((s) => s.a.trip?.id === trip.id);
433
- const coaches = travel?.coaches ?? offering.fixed_coaches ?? coachesFor(offering, ctx.enrollment.get(offering.id) ?? 0);
414
+ const travel = (a) => a.kind === "travel" && a.trip?.id === trip.id;
415
+ const staffed = [...blocks.values()].filter((b) => b.members.some(travel));
416
+ const coaches = Math.max(0, ...staffed.map((b) => sum(b.staff.map((s) => s.count))));
417
+ if (coaches === 0 && ((trip.airfare_cents ?? 0) > 0 || (trip.nights ?? 0) > 0)) {
418
+ const first = scheduled
419
+ .filter(travel)
420
+ .map((a) => a.date)
421
+ .sort()[0];
422
+ this.warnOnce("trip_without_staff", `${trip.name} has no staff on a travel day's block, so no coach's airfare or lodging is charged`, trip.key, { version_id: trip.version_id, ...(first !== undefined && { date: first }) });
423
+ }
434
424
  const lodging = ctx.rates?.lodging_per_night_cents ?? null;
435
425
  const mileage = ctx.rates?.mileage_rate_cents ?? null;
436
426
  if ((trip.nights ?? 0) > 0 && lodging === null) {
@@ -460,37 +450,56 @@ export class Staffing {
460
450
  }
461
451
  }
462
452
  }
463
- function payOf(offering) {
464
- return offering.coach_position_pay ?? "salary";
453
+ const byDate = (blocks) => [...blocks.values()].sort((x, y) => x.row.date.localeCompare(y.row.date));
454
+ /** The first start and last end among `list`; both null when one has no times. */
455
+ function timeSpan(list) {
456
+ if (list.length === 0 || list.some((a) => a.start_minutes === null || a.end_minutes === null)) {
457
+ return { start: null, end: null };
458
+ }
459
+ return {
460
+ start: Math.min(...list.map((a) => a.start_minutes)),
461
+ end: Math.max(...list.map((a) => a.end_minutes)),
462
+ };
465
463
  }
466
- /** Whether two activities' times overlap. One with no times overlaps everything on its date. */
464
+ /** Whether two blocks' times overlap. One with no times overlaps everything on its date. */
467
465
  function overlaps(x, y) {
468
- const [xs, xe, ys, ye] = [x.a.start_minutes, x.a.end_minutes, y.a.start_minutes, y.a.end_minutes];
469
- if (xs === null || xe === null || ys === null || ye === null)
466
+ if (x.start === null || x.end === null || y.start === null || y.end === null)
470
467
  return true;
471
- return xs < ye && ys < xe;
468
+ return x.start < y.end && y.start < x.end;
472
469
  }
473
470
  /**
474
- * Minutes another coach would cover among one position's activities on one date: at each moment
475
- * it's on n groups, n − 1. A block group staffed together is one. Untimed activities add nothing.
471
+ * The most of something out at one moment, and the blocks that had it then. A block with no times
472
+ * is out all day.
473
+ */
474
+ function mostAtOnce(out) {
475
+ let best = { total: 0, at: [] };
476
+ for (const candidate of out) {
477
+ // The peak starts when some block starts, so each block's start is a moment to check.
478
+ const at = out.filter((o) => o === candidate || overlapsAt(o.block, candidate.block));
479
+ const total = sum(at.map((o) => o.n));
480
+ if (total > best.total)
481
+ best = { total, at };
482
+ }
483
+ return best;
484
+ }
485
+ /** Whether `x` is out at the moment `y` starts. */
486
+ function overlapsAt(x, y) {
487
+ if (x.start === null || x.end === null || y.start === null)
488
+ return true;
489
+ return x.start <= y.start && y.start < x.end;
490
+ }
491
+ /**
492
+ * Minutes another coach would cover among one position's blocks on one date: at each moment it's on
493
+ * n blocks, n − 1. Blocks with no times add nothing.
476
494
  */
477
495
  function extraCoachMinutes(list) {
478
- const spans = new Map();
479
- list.forEach((s, i) => {
480
- if (s.a.start_minutes === null || s.a.end_minutes === null)
481
- return;
482
- const id = s.group ?? `own|${i}`;
483
- const span = spans.get(id);
484
- spans.set(id, {
485
- start: Math.min(span?.start ?? Infinity, s.a.start_minutes),
486
- end: Math.max(span?.end ?? -Infinity, s.a.end_minutes),
487
- });
488
- });
489
- const edges = [...spans.values()]
490
- .flatMap(({ start, end }) => [
491
- { at: start, step: 1 },
492
- { at: end, step: -1 },
493
- ])
496
+ const edges = list
497
+ .flatMap((b) => b.start === null || b.end === null
498
+ ? []
499
+ : [
500
+ { at: b.start, step: 1 },
501
+ { at: b.end, step: -1 },
502
+ ])
494
503
  .sort((x, y) => x.at - y.at || x.step - y.step);
495
504
  let on = 0;
496
505
  let last = 0;