@cyc-seattle/platform 1.20.1 → 1.22.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.
@@ -6,6 +6,9 @@ export interface ScheduledActivity {
6
6
  trip: PlanTrip | null;
7
7
  stored: PlanActivity;
8
8
  date: string;
9
+ /** Minutes after midnight, or null when the activity has no times. */
10
+ start_minutes: number | null;
11
+ end_minutes: number | null;
9
12
  kind: ActivityKind;
10
13
  paid_minutes: number;
11
14
  on_water_minutes: number;
@@ -5,6 +5,15 @@ import { minutesOf } from "./dates.js";
5
5
  */
6
6
  export function scheduleActivities(ctx) {
7
7
  const trips = new Map(ctx.data.plan_trips.map((t) => [t.id, t]));
8
+ const closed = new Map();
9
+ for (const c of ctx.data.plan_closed_dates ?? []) {
10
+ // The club's closed days are the Operations Plan's, not an area's.
11
+ if (ctx.planOfVersion.get(c.version_id)?.kind === "program_area") {
12
+ ctx.warn("closed_date_in_area_plan", `${c.name} is a closed day in a program area's plan, so it's ignored`, c.key);
13
+ continue;
14
+ }
15
+ closed.set(c.date, c.name);
16
+ }
8
17
  const result = [];
9
18
  for (const activity of ctx.data.plan_activities) {
10
19
  const offering = ctx.offeringsById.get(activity.offering_id);
@@ -14,15 +23,25 @@ export function scheduleActivities(ctx) {
14
23
  }
15
24
  if (!ctx.inYear(offering))
16
25
  continue;
17
- const span = activity.start_time != null && activity.end_time != null
18
- ? minutesOf(activity.end_time) - minutesOf(activity.start_time)
19
- : 0;
26
+ const timed = activity.start_time != null && activity.end_time != null;
27
+ const start = timed ? minutesOf(activity.start_time) : null;
28
+ let end = timed ? minutesOf(activity.end_time) : null;
29
+ // An activity that ends before it starts runs past midnight.
30
+ if (start !== null && end !== null && end < start)
31
+ end += 24 * 60;
32
+ const span = start !== null && end !== null ? end - start : 0;
33
+ const closedFor = closed.get(activity.date);
34
+ if (closedFor !== undefined && activity.kind !== "travel") {
35
+ ctx.warn("closed_date", `${offering.name} has an activity on ${activity.date}, when the club is closed for ${closedFor}`, activity.key);
36
+ }
20
37
  const usesBoats = activity.kind !== "travel" && activity.uses_boats;
21
38
  result.push({
22
39
  offering,
23
40
  trip: activity.trip_id == null ? null : (trips.get(activity.trip_id) ?? null),
24
41
  stored: activity,
25
42
  date: activity.date,
43
+ start_minutes: start,
44
+ end_minutes: end,
26
45
  kind: activity.kind,
27
46
  paid_minutes: span + (activity.setup_minutes ?? 0) + (activity.putaway_minutes ?? 0),
28
47
  on_water_minutes: usesBoats ? (activity.on_water_minutes ?? span) : 0,
@@ -1,6 +1,10 @@
1
1
  import type { Context } from "./context.ts";
2
+ import type { Id } from "./types.ts";
2
3
  /**
3
- * Records each bonus owner's `bonus_rate × max(0, combined net)` for the plan year, after every pool.
4
- * It's divided among the owned products' offerings by revenue, and is never counted in MTDC.
4
+ * Records each bonus owner's `bonus_rate` × its products' net for the year, above zero, after the
5
+ * pools or before them. Every base is worked out before any bonus is booked, so one bonus never
6
+ * counts in another's base. Each bonus is divided among the owned offerings by revenue, with the
7
+ * year's payroll tax on it, and neither is counted in MTDC. Returns each position's bonus, by
8
+ * position key.
5
9
  */
6
- export declare function bookBonuses(ctx: Context): void;
10
+ export declare function bookBonuses(ctx: Context): Map<Id, number>;
@@ -1,49 +1,105 @@
1
1
  import { revenue } from "./measures.js";
2
2
  /**
3
- * Records each bonus owner's `bonus_rate × max(0, combined net)` for the plan year, after every pool.
4
- * It's divided among the owned products' offerings by revenue, and is never counted in MTDC.
3
+ * Records each bonus owner's `bonus_rate` × its products' net for the year, above zero, after the
4
+ * pools or before them. Every base is worked out before any bonus is booked, so one bonus never
5
+ * counts in another's base. Each bonus is divided among the owned offerings by revenue, with the
6
+ * year's payroll tax on it, and neither is counted in MTDC. Returns each position's bonus, by
7
+ * position key.
5
8
  */
6
9
  export function bookBonuses(ctx) {
7
- for (const owner of ctx.data.plan_bonus_owners) {
8
- const products = new Set(ctx.data.plan_bonus_owner_products.filter((p) => p.bonus_owner_id === owner.id).map((p) => p.product_id));
9
- const offeringKeys = new Set(ctx
10
- .fyOfferings()
11
- .filter((o) => products.has(o.product_id))
12
- .map((o) => o.key));
13
- let net = 0;
14
- for (const e of ctx.entries) {
15
- const owned = (e.cost_center_type === "offering" && e.cost_center_key != null && offeringKeys.has(e.cost_center_key)) ||
16
- (e.cost_center_type === "product" && e.cost_center_key != null && products.has(e.cost_center_key));
17
- if (!owned)
18
- continue;
19
- const type = ctx.accountType(e.account_id);
20
- if (type === "revenue")
21
- net += e.amount;
22
- else if (type === "expense")
23
- net -= e.amount;
24
- }
25
- const bonus = owner.bonus_rate * Math.max(0, net);
10
+ const bonuses = ctx.data.plan_bonus_owners.map((owner) => ({ owner, base: baseOf(ctx, owner) }));
11
+ const taxRate = ctx.rates?.payroll_tax_rate ?? 0;
12
+ const taxAccount = taxRate > 0 && bonuses.length > 0 ? ctx.costAccount("payroll_tax") : undefined;
13
+ const byPosition = new Map();
14
+ for (const { owner, base } of bonuses) {
15
+ const bonus = owner.bonus_rate * Math.max(0, base.net);
26
16
  if (bonus === 0)
27
17
  continue;
28
18
  const weights = new Map();
29
- for (const key of offeringKeys) {
19
+ for (const key of base.offeringKeys) {
30
20
  const r = revenue(ctx, key);
31
21
  if (r > 0)
32
22
  weights.set(key, r);
33
23
  }
24
+ if (weights.size === 0) {
25
+ if (base.offeringKeys.length === 0) {
26
+ ctx.warn("bonus_without_offerings", `${owner.role_name}'s bonus has no offerings this year to charge it to, so it's left out`, owner.key);
27
+ continue;
28
+ }
29
+ // Its net came from elsewhere, such as revenue booked on a product, so its offerings share it evenly.
30
+ ctx.warn("bonus_without_revenue", `${owner.role_name}'s offerings have no revenue of their own, so its bonus is split evenly among them`, owner.key);
31
+ for (const key of base.offeringKeys)
32
+ weights.set(key, 1);
33
+ }
34
34
  const total = [...weights.values()].reduce((t, w) => t + w, 0);
35
+ if (owner.position_key != null) {
36
+ byPosition.set(owner.position_key, (byPosition.get(owner.position_key) ?? 0) + bonus);
37
+ }
35
38
  for (const [key, w] of weights) {
39
+ const amount = (bonus * w) / total;
36
40
  ctx.book({
37
41
  cost_center_type: "offering",
38
42
  cost_center_key: key,
39
43
  account_id: owner.account_id,
40
44
  function: ctx.functionFor(owner.account_id, null, "offering"),
41
- amount: (bonus * w) / total,
45
+ amount,
42
46
  line_key: `bonus:${owner.key}`,
43
- explanation: `${owner.role_name}: ${owner.bonus_rate} × combined net of its products`,
47
+ explanation: `${owner.role_name}: ${owner.bonus_rate} × net of its products`,
48
+ stage: "bonus",
49
+ mtdc_share: 0,
50
+ });
51
+ if (taxAccount === undefined)
52
+ continue;
53
+ ctx.book({
54
+ cost_center_type: "offering",
55
+ cost_center_key: key,
56
+ account_id: taxAccount,
57
+ function: ctx.functionFor(owner.account_id, null, "offering"),
58
+ amount: amount * taxRate,
59
+ line_key: `bonus_payroll_tax:${owner.key}`,
60
+ explanation: `${taxRate} × ${owner.role_name}'s bonus`,
44
61
  stage: "bonus",
45
62
  mtdc_share: 0,
46
63
  });
47
64
  }
48
65
  }
66
+ return byPosition;
67
+ }
68
+ /**
69
+ * The owner's products' net for the year, and their offerings. An area plan's bonus counts only that
70
+ * area's products, and warns about any other.
71
+ */
72
+ function baseOf(ctx, owner) {
73
+ const plan = ctx.planOfVersion.get(owner.version_id);
74
+ const products = new Set();
75
+ for (const row of ctx.data.plan_bonus_owner_products) {
76
+ if (row.bonus_owner_id !== owner.id)
77
+ continue;
78
+ const product = ctx.products.get(row.product_id);
79
+ if (plan?.kind === "program_area" && product?.program_area_id !== plan.program_area_id) {
80
+ ctx.warn("bonus_outside_area", `${owner.role_name}'s bonus names ${product?.name ?? row.product_id}, which isn't in its plan's program area, so it's left out`, owner.key);
81
+ continue;
82
+ }
83
+ products.add(row.product_id);
84
+ }
85
+ const offeringKeys = new Set(ctx
86
+ .fyOfferings()
87
+ .filter((o) => products.has(o.product_id))
88
+ .map((o) => o.key));
89
+ const afterPools = (owner.basis ?? "after_shared_costs") === "after_shared_costs";
90
+ let net = 0;
91
+ for (const e of ctx.entries) {
92
+ if (e.stage === "bonus" || (!afterPools && e.stage === "pool") || e.cost_center_key == null)
93
+ continue;
94
+ const owned = (e.cost_center_type === "offering" && offeringKeys.has(e.cost_center_key)) ||
95
+ (e.cost_center_type === "product" && products.has(e.cost_center_key));
96
+ if (!owned)
97
+ continue;
98
+ const type = ctx.accountType(e.account_id);
99
+ if (type === "revenue")
100
+ net += e.amount;
101
+ else if (type === "expense")
102
+ net -= e.amount;
103
+ }
104
+ return { net, offeringKeys: [...offeringKeys] };
49
105
  }
@@ -23,18 +23,19 @@ export function computePlan(data) {
23
23
  const booker = new CostCenterBooker(ctx);
24
24
  bookBudgetLines(ctx, booker);
25
25
  bookLaborLines(ctx, booker);
26
- bookSalaries(ctx, coaching, booker);
26
+ const charged = bookSalaries(ctx, coaching, booker);
27
27
  booker.flush();
28
28
  bookPayrollTax(ctx);
29
29
  const mtdcByOffering = new Map(ctx.fyOfferings().map((o) => [o.key, mtdc(ctx, o.key)]));
30
30
  allocatePools(ctx);
31
- bookBonuses(ctx);
31
+ const bonuses = bookBonuses(ctx);
32
32
  const lines = sumLines(ctx);
33
33
  return {
34
34
  lines,
35
35
  pnl: summarize(ctx, lines, mtdcByOffering),
36
36
  organization: organizationTotals(ctx, lines),
37
37
  activities,
38
+ positions: summarizePositions(ctx, coaching, charged, bonuses),
38
39
  warnings: ctx.warnings,
39
40
  };
40
41
  }
@@ -66,6 +67,41 @@ function checkAreas(ctx) {
66
67
  }
67
68
  }
68
69
  }
70
+ /** Each salaried position's year: its salary, where its hours went, and its hourly wages and bonus. */
71
+ function summarizePositions(ctx, coaching, charged, bonuses) {
72
+ const taxRate = ctx.rates?.payroll_tax_rate ?? 0;
73
+ const known = new Set(ctx.data.plan_salaried_positions.map((p) => p.key));
74
+ const named = [
75
+ ...coaching.map((c) => c.position_key),
76
+ ...ctx.data.plan_bonus_owners.flatMap((o) => (o.position_key == null ? [] : [o.position_key])),
77
+ ];
78
+ for (const key of new Set(named)) {
79
+ if (!known.has(key)) {
80
+ ctx.warn("unknown_position", `A block, offering or bonus names missing salaried position ${key}`, key);
81
+ }
82
+ }
83
+ return ctx.data.plan_salaried_positions.map((position) => {
84
+ const hourly = coaching.filter((c) => c.position_key === position.key && c.pay === "hourly");
85
+ const charges = charged.get(position.key);
86
+ const hourlyCents = hourly.reduce((t, c) => t + c.wages, 0);
87
+ const bonus = bonuses.get(position.key) ?? 0;
88
+ const total = position.annual_salary_cents + hourlyCents + bonus;
89
+ return {
90
+ position_key: position.key,
91
+ name: position.name,
92
+ salary_cents: position.annual_salary_cents,
93
+ salaried_coaching_hours: charges?.coaching_hours ?? 0,
94
+ salaried_coaching_cents: Math.round(charges?.coaching_cents ?? 0),
95
+ hourly_coaching_hours: hourly.reduce((t, c) => t + c.hours, 0),
96
+ hourly_coaching_cents: Math.round(hourlyCents),
97
+ booking_hours: charges?.booking_hours ?? 0,
98
+ booking_cents: Math.round(charges?.booking_cents ?? 0),
99
+ bonus_cents: Math.round(bonus),
100
+ total_pay_cents: Math.round(total),
101
+ payroll_tax_cents: Math.round(total * taxRate),
102
+ };
103
+ });
104
+ }
69
105
  /**
70
106
  * The organization's year from every line. Capital is any line on an asset account; fundraising is
71
107
  * revenue and expense whose function is `fundraising`; everything else is operating.
@@ -1,9 +1,17 @@
1
1
  import type { Context } from "./context.ts";
2
2
  import type { CostCenterBooker } from "./lines.ts";
3
3
  import type { SalariedCoaching } from "./staffing.ts";
4
+ import type { Id } from "./types.ts";
5
+ /** What a position's hours charged to programs, by position key, for its summary. */
6
+ export interface PositionCharges {
7
+ coaching_hours: number;
8
+ coaching_cents: number;
9
+ booking_hours: number;
10
+ booking_cents: number;
11
+ }
4
12
  /**
5
13
  * Charges each salaried position's salary: its coaching and management hours at its hourly
6
14
  * equivalent go to what they served, and the rest, including its `ga_share` off the top, goes to its
7
15
  * remainder pool, or the organization if it has none.
8
16
  */
9
- export declare function bookSalaries(ctx: Context, coaching: SalariedCoaching[], booker: CostCenterBooker): void;
17
+ export declare function bookSalaries(ctx: Context, coaching: SalariedCoaching[], booker: CostCenterBooker): Map<Id, PositionCharges>;
@@ -4,18 +4,13 @@
4
4
  * remainder pool, or the organization if it has none.
5
5
  */
6
6
  export function bookSalaries(ctx, coaching, booker) {
7
+ const charged = new Map();
7
8
  if (ctx.data.plan_salaried_positions.length === 0)
8
- return;
9
+ return charged;
9
10
  const account = ctx.costAccount("salaries");
10
11
  if (account === undefined)
11
- return;
12
+ return charged;
12
13
  const pools = new Map(ctx.data.plan_pools.map((p) => [p.id, p]));
13
- const known = new Set(ctx.data.plan_salaried_positions.map((p) => p.key));
14
- for (const c of coaching) {
15
- if (!known.has(c.position_key)) {
16
- ctx.warn("unknown_position", `A block rule names missing salaried position ${c.position_key}`, c.position_key);
17
- }
18
- }
19
14
  for (const position of ctx.data.plan_salaried_positions) {
20
15
  const salary = position.annual_salary_cents;
21
16
  const rate = position.paid_hours_per_year != null && position.paid_hours_per_year > 0
@@ -23,7 +18,7 @@ export function bookSalaries(ctx, coaching, booker) {
23
18
  : null;
24
19
  const charges = [
25
20
  ...coaching
26
- .filter((c) => c.position_key === position.key)
21
+ .filter((c) => c.position_key === position.key && c.pay === "salary")
27
22
  .map((c) => ({
28
23
  cost_center_type: "offering",
29
24
  cost_center_key: c.offering_key,
@@ -58,6 +53,19 @@ export function bookSalaries(ctx, coaching, booker) {
58
53
  if (scale < 1) {
59
54
  ctx.warn("position_overbooked", `${position.name}'s hours charged to programs cost more than its salary after ga_share, so they're scaled down`, position.key);
60
55
  }
56
+ const sums = { coaching_hours: 0, coaching_cents: 0, booking_hours: 0, booking_cents: 0 };
57
+ for (const c of charges) {
58
+ const amount = c.hours * (rate ?? 0) * scale;
59
+ if (c.line === "coaching") {
60
+ sums.coaching_hours += c.hours;
61
+ sums.coaching_cents += amount;
62
+ }
63
+ else {
64
+ sums.booking_hours += c.hours;
65
+ sums.booking_cents += amount;
66
+ }
67
+ }
68
+ charged.set(position.key, sums);
61
69
  for (const c of charges) {
62
70
  booker.add({
63
71
  cost_center_type: c.cost_center_type,
@@ -82,4 +90,5 @@ export function bookSalaries(ctx, coaching, booker) {
82
90
  wages: true,
83
91
  });
84
92
  }
93
+ return charged;
85
94
  }
@@ -6,6 +6,10 @@ export interface SalariedCoaching {
6
6
  position_key: Id;
7
7
  offering_key: Id;
8
8
  hours: number;
9
+ /** `salary` hours are charged at the salary's hourly equivalent; `hourly` ones are paid as wages. */
10
+ pay: "salary" | "hourly";
11
+ /** The wages for `hourly` hours. */
12
+ wages: number;
9
13
  }
10
14
  /** Whole kids: `target_enrollment` if set, otherwise `capacity × fill_rate` rounded. */
11
15
  export declare function enrollmentOf(offering: PlanOffering): number;
@@ -29,6 +33,18 @@ export declare class Staffing {
29
33
  * count an activity fixes keeps it out of that part of the group.
30
34
  */
31
35
  private staffBlock;
36
+ /** An offering's named coach, on an activity outside any block: one of its coaches. */
37
+ private staffNamedCoach;
38
+ /**
39
+ * Splits a block's activities on one date into groups whose times overlap. An activity with no
40
+ * times overlaps every other.
41
+ */
42
+ private overlapping;
43
+ /**
44
+ * Warns when a salaried position coaches two activities at once that aren't staffed as one block
45
+ * group. It's still charged to both.
46
+ */
47
+ private checkDoubleBooked;
32
48
  /**
33
49
  * One RIB's hourly and daily cost, averaged over the RIB resources by `quantity`, and the part of
34
50
  * it from resources excluded from MTDC, such as rentals.
@@ -115,8 +115,10 @@ export class Staffing {
115
115
  fixed: { coaches: fixedCoaches != null, ribs: fixedRibs != null, boats: fixedBoats != null },
116
116
  salaried: 0,
117
117
  positions: [],
118
+ hourlyPositions: [],
118
119
  level: undefined,
119
120
  block: null,
121
+ group: null,
120
122
  };
121
123
  }
122
124
  blockFor(a) {
@@ -168,15 +170,89 @@ export class Staffing {
168
170
  return [c, totalSailors > 0 ? ((shared.get(c) ?? 0) * n) / totalSailors : 0];
169
171
  }));
170
172
  }
171
- // The block's salaried positions take its first coaching slots, so fewer hourly coaches are paid.
172
- const positions = block.salaried_positions ?? [];
173
- if (positions.length > 0 && coachTotal > 0) {
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) {
174
183
  const salariedTotal = Math.min(positions.length, coachTotal);
184
+ const hourlyTotal = Math.min(hourlyPositions.length, coachTotal - salariedTotal);
175
185
  for (const s of coached) {
176
186
  s.salaried = (salariedTotal * s.coaches) / coachTotal;
177
- s.positions = positions.map((key) => ({ key, coaches: s.salaried / positions.length }));
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 })) : [];
192
+ }
193
+ }
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)
199
+ 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 }];
204
+ }
205
+ else {
206
+ s.hourlyPositions = [{ key, coaches }];
207
+ }
208
+ }
209
+ /**
210
+ * Splits a block's activities on one date into groups whose times overlap. An activity with no
211
+ * times overlaps every other.
212
+ */
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);
224
+ }
225
+ else {
226
+ groups.push([s]);
227
+ end = s.a.end_minutes;
178
228
  }
179
229
  }
230
+ return groups;
231
+ }
232
+ /**
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.
235
+ */
236
+ checkDoubleBooked(staffed) {
237
+ const byDay = new Map();
238
+ for (const s of staffed) {
239
+ for (const p of [...s.positions, ...s.hourlyPositions]) {
240
+ if (p.coaches <= 0)
241
+ continue;
242
+ const id = `${p.key}|${s.a.date}`;
243
+ byDay.set(id, [...(byDay.get(id) ?? []), s]);
244
+ }
245
+ }
246
+ const names = new Map(this.ctx.data.plan_salaried_positions.map((p) => [p.key, p.name]));
247
+ for (const [id, list] of byDay) {
248
+ const [key, date] = id.split("|");
249
+ const clash = list.flatMap((x, i) => list
250
+ .slice(i + 1)
251
+ .flatMap((y) => ((x.group !== null && x.group === y.group) || !overlaps(x, y) ? [] : [[x, y]])))[0];
252
+ if (clash === undefined)
253
+ continue;
254
+ 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}`, key);
255
+ }
180
256
  }
181
257
  /**
182
258
  * One RIB's hourly and daily cost, averaged over the RIB resources by `quantity`, and the part of
@@ -204,18 +280,26 @@ export class Staffing {
204
280
  run(scheduled) {
205
281
  const { ctx } = this;
206
282
  const staffed = scheduled.map((a) => this.initial(a));
207
- const groups = new Map();
283
+ const days = new Map();
208
284
  for (const s of staffed) {
209
285
  const block = this.blockFor(s.a);
210
- if (block === null)
286
+ if (block === null) {
287
+ this.staffNamedCoach(s);
211
288
  continue;
289
+ }
212
290
  const id = `${block.id}|${s.a.date}`;
213
- const group = groups.get(id) ?? { block, members: [] };
214
- group.members.push(s);
215
- groups.set(id, group);
291
+ const day = days.get(id) ?? { block, members: [] };
292
+ day.members.push(s);
293
+ days.set(id, day);
216
294
  }
217
- for (const { block, members } of groups.values())
218
- this.staffBlock(block, members);
295
+ for (const [id, { block, members }] of days) {
296
+ this.overlapping(members).forEach((group, i) => {
297
+ for (const s of group)
298
+ s.group = `${id}|${i}`;
299
+ this.staffBlock(block, group);
300
+ });
301
+ }
302
+ this.checkDoubleBooked(staffed);
219
303
  const coaching = [];
220
304
  const resourcesByActivity = new Map();
221
305
  for (const r of ctx.data.plan_activity_resources) {
@@ -262,7 +346,17 @@ export class Staffing {
262
346
  const wages = hourly * paidHours * rate;
263
347
  book("coach_wages", wages, "coach_wages", "Hourly coaches × paid hours × the coach level's rate");
264
348
  for (const p of s.positions) {
265
- coaching.push({ position_key: p.key, offering_key: key, hours: p.coaches * paidHours });
349
+ coaching.push({
350
+ position_key: p.key,
351
+ offering_key: key,
352
+ hours: p.coaches * paidHours,
353
+ pay: "salary",
354
+ wages: 0,
355
+ });
356
+ }
357
+ for (const p of s.hourlyPositions) {
358
+ const hours = p.coaches * paidHours;
359
+ coaching.push({ position_key: p.key, offering_key: key, hours, pay: "hourly", wages: hours * rate });
266
360
  }
267
361
  if (s.ribs > 0 && waterHours > 0) {
268
362
  if (ribRate === undefined) {
@@ -352,6 +446,16 @@ export class Staffing {
352
446
  }
353
447
  }
354
448
  }
449
+ function payOf(offering) {
450
+ return offering.coach_position_pay ?? "salary";
451
+ }
452
+ /** Whether two activities' times overlap. One with no times overlaps everything on its date. */
453
+ function overlaps(x, y) {
454
+ const [xs, xe, ys, ye] = [x.a.start_minutes, x.a.end_minutes, y.a.start_minutes, y.a.end_minutes];
455
+ if (xs === null || xe === null || ys === null || ye === null)
456
+ return true;
457
+ return xs < ye && ys < xe;
458
+ }
355
459
  function hourlyCost(r) {
356
460
  return (r.cost_per_hour_cents ?? 0) + (r.fuel_per_hour_cents ?? 0) + (r.maintenance_per_hour_cents ?? 0);
357
461
  }
@@ -108,6 +108,10 @@ export interface PlanOffering extends Versioned {
108
108
  fixed_boats: number | null;
109
109
  cogs_rate: number | null;
110
110
  note: string | null;
111
+ /** A `plan_salaried_positions` key: the salaried coach who takes one coach on each activity. */
112
+ coach_position_key?: Id | null;
113
+ /** How that coach is paid for it: out of the salary (the default), or by the hour at the offering's level. */
114
+ coach_position_pay?: "salary" | "hourly" | null;
111
115
  }
112
116
  export interface PlanOfferingBoatClass extends Versioned {
113
117
  offering_id: Id;
@@ -236,6 +240,10 @@ export interface PlanBonusOwner extends Versioned {
236
240
  role_name: string;
237
241
  bonus_rate: number;
238
242
  account_id: Id;
243
+ /** Whether the net is counted after the pools are shared out (the default), or before. */
244
+ basis?: "after_shared_costs" | "before_shared_costs" | null;
245
+ /** A `plan_salaried_positions` key: the position the bonus pays. */
246
+ position_key?: Id | null;
239
247
  }
240
248
  export interface PlanBonusOwnerProduct extends Versioned {
241
249
  bonus_owner_id: Id;
@@ -253,6 +261,11 @@ export interface PlanRevenueCharge extends Versioned {
253
261
  excluded_from_mtdc: boolean;
254
262
  note: string | null;
255
263
  }
264
+ /** A day the club is closed, such as Labor Day. */
265
+ export interface PlanClosedDate extends Versioned {
266
+ date: DateString;
267
+ name: string;
268
+ }
256
269
  /** The catalog plus every row of the plan versions being calculated. */
257
270
  export interface PlanData {
258
271
  /** The plan year. Fiscal year N ends in calendar year N. */
@@ -289,6 +302,7 @@ export interface PlanData {
289
302
  plan_pools: PlanPool[];
290
303
  plan_bonus_owners: PlanBonusOwner[];
291
304
  plan_bonus_owner_products: PlanBonusOwnerProduct[];
305
+ plan_closed_dates?: PlanClosedDate[];
292
306
  }
293
307
  /** One line of cost or revenue for the plan year, on one offering, product, pool or the organization. */
294
308
  export interface CostLine {
@@ -359,11 +373,34 @@ export interface OrganizationTotals {
359
373
  /** Operating net, plus fundraising net, less capital. */
360
374
  total_cents: number;
361
375
  }
376
+ /** One salaried position's year: what it's paid, and where its hours went. */
377
+ export interface PositionSummary {
378
+ position_key: Id;
379
+ name: string;
380
+ salary_cents: number;
381
+ /** Hours it coached out of its salary, and what they charged to the offerings. */
382
+ salaried_coaching_hours: number;
383
+ salaried_coaching_cents: number;
384
+ /** Hours it coached for an hourly wage, on top of its salary, and the wages. */
385
+ hourly_coaching_hours: number;
386
+ hourly_coaching_cents: number;
387
+ /** Its `plan_salaried_bookings` hours, and what they charged. */
388
+ booking_hours: number;
389
+ booking_cents: number;
390
+ /** Every bonus that names it. */
391
+ bonus_cents: number;
392
+ /** Salary, hourly wages and bonus. */
393
+ total_pay_cents: number;
394
+ /** The year's payroll tax on its total pay. */
395
+ payroll_tax_cents: number;
396
+ }
362
397
  export interface EngineOutput {
363
398
  lines: CostLine[];
364
399
  /** Each offering's P&L, then each product's, then each program area's. */
365
400
  pnl: Pnl[];
366
401
  organization: OrganizationTotals;
367
402
  activities: ComputedActivity[];
403
+ /** Each salaried position's pay for the year. */
404
+ positions: PositionSummary[];
368
405
  warnings: EngineWarning[];
369
406
  }
@@ -35,12 +35,19 @@ export interface Schema {
35
35
  plan_bonus_owner_products: PlanBonusOwnerProducts[];
36
36
  plan_bonus_owners: PlanBonusOwners[];
37
37
  plan_budget_lines: PlanBudgetLines[];
38
+ plan_closed_dates: PlanClosedDates[];
38
39
  plan_labor_lines: PlanLaborLines[];
39
40
  plan_offering_boat_classes: PlanOfferingBoatClasses[];
40
41
  plan_offerings: PlanOfferings[];
41
42
  plan_pools: PlanPools[];
42
43
  plan_rates: PlanRates[];
43
44
  plan_resources: PlanResources[];
45
+ plan_result_lines: PlanResultLines[];
46
+ plan_result_pnl: PlanResultPnl[];
47
+ plan_result_positions: PlanResultPositions[];
48
+ plan_result_versions: PlanResultVersions[];
49
+ plan_result_warnings: PlanResultWarnings[];
50
+ plan_results: PlanResults[];
44
51
  plan_revenue_charges: PlanRevenueCharges[];
45
52
  plan_salaried_bookings: PlanSalariedBookings[];
46
53
  plan_salaried_positions: PlanSalariedPositions[];
@@ -511,7 +518,7 @@ export interface PlanBonusOwnerProducts {
511
518
  bonus_owner_id: string | PlanBonusOwners;
512
519
  product_id: string | Products;
513
520
  }
514
- /** A role, such as a head coach, that earns a bonus on the products it owns. */
521
+ /** A role, such as a program area's director, that earns a bonus on the products it owns. */
515
522
  export interface PlanBonusOwners {
516
523
  id: string;
517
524
  /** The version this row belongs to. */
@@ -519,9 +526,13 @@ export interface PlanBonusOwners {
519
526
  /** Stays the same across versions. Copy it where another row asks for this one's Key. */
520
527
  key: string;
521
528
  role_name: string;
522
- /** A share of its products' combined net, after pools. 0.05 means 5%. */
529
+ /** A share of its products' net. 0.05 means 5%. */
523
530
  bonus_rate: string;
524
531
  account_id: string | Accounts;
532
+ /** Whether the net is counted after each offering's share of the pools, such as G&A, or before it. */
533
+ basis: "after_shared_costs" | "before_shared_costs" | null;
534
+ /** The salaried position this bonus pays, if any: copy the Key from a salaried position in the Operations Plan. */
535
+ position_key: string | null;
525
536
  }
526
537
  /** A cost or revenue entered as an amount. */
527
538
  export interface PlanBudgetLines {
@@ -543,6 +554,17 @@ export interface PlanBudgetLines {
543
554
  excluded_from_mtdc: boolean;
544
555
  note: string | null;
545
556
  }
557
+ /** A day the club is closed, such as Labor Day. The P&L warns about an activity on one. */
558
+ export interface PlanClosedDates {
559
+ id: string;
560
+ /** The version this row belongs to. */
561
+ version_id: string | PlanVersions;
562
+ /** Stays the same across versions. Copy it where another row asks for this one's Key. */
563
+ key: string;
564
+ date: string;
565
+ /** Why it's closed, such as Labor Day. */
566
+ name: string;
567
+ }
546
568
  /** Labor not tied to an activity, such as training, priced at a staff level. */
547
569
  export interface PlanLaborLines {
548
570
  id: string;
@@ -609,6 +631,10 @@ export interface PlanOfferings {
609
631
  /** Cost of goods sold, as a share of revenue. 0.1 means 10%. */
610
632
  cogs_rate: string | null;
611
633
  note: string | null;
634
+ /** A salaried coach who coaches every practice: copy the Key from a salaried position in the Operations Plan. */
635
+ coach_position_key: string | null;
636
+ /** Out of their salary, or by the hour at the offering's coach level, on top of their salary. */
637
+ coach_position_pay: "salary" | "hourly" | null;
612
638
  }
613
639
  /** Shared costs no single offering owns, such as G&A, divided among the year's offerings by MTDC. */
614
640
  export interface PlanPools {
@@ -656,6 +682,108 @@ export interface PlanResources {
656
682
  /** Set for a rental, whose cost is left out of modified total direct costs (MTDC). */
657
683
  excluded_from_mtdc: boolean;
658
684
  }
685
+ /** Each cost and revenue line in a saved version's P&L. Worked out when the version was saved, and never changed. */
686
+ export interface PlanResultLines {
687
+ id: string;
688
+ /** The saved version these results are for. */
689
+ version_id: string | PlanVersions;
690
+ cost_center_type: "offering" | "product" | "program_area" | "pool" | "org";
691
+ cost_center_key: string | null;
692
+ cost_center_name: string;
693
+ account_id: string | Accounts;
694
+ function: "program" | "management" | "fundraising" | null;
695
+ /** Always positive: the account says whether it's revenue or a cost. In cents: 12500 is $125.00. */
696
+ amount_cents: number;
697
+ /** What the line is, as the P&L names it. */
698
+ line: string;
699
+ explanation: string | null;
700
+ }
701
+ /** A saved version's P&L for each offering, product, and program area. Worked out when the version was saved, and never changed. */
702
+ export interface PlanResultPnl {
703
+ id: string;
704
+ /** The saved version these results are for. */
705
+ version_id: string | PlanVersions;
706
+ level: "offering" | "product" | "program_area";
707
+ subject_key: string;
708
+ name: string;
709
+ /** In cents: 12500 is $125.00. */
710
+ revenue_cents: number;
711
+ /** In cents: 12500 is $125.00. */
712
+ expense_cents: number;
713
+ /** In cents: 12500 is $125.00. */
714
+ net_cents: number;
715
+ /** Modified total direct costs, which shared costs are divided by. In cents: 12500 is $125.00. */
716
+ mtdc_cents: number;
717
+ enrollment: number;
718
+ days: number;
719
+ sailor_days: number;
720
+ }
721
+ /** Each salaried position's pay for the year, in a saved version. Worked out when the version was saved, and never changed. */
722
+ export interface PlanResultPositions {
723
+ id: string;
724
+ /** The saved version these results are for. */
725
+ version_id: string | PlanVersions;
726
+ position_key: string;
727
+ name: string;
728
+ /** In cents: 12500 is $125.00. */
729
+ salary_cents: number;
730
+ salaried_coaching_hours: string;
731
+ /** In cents: 12500 is $125.00. */
732
+ salaried_coaching_cents: number;
733
+ hourly_coaching_hours: string;
734
+ /** In cents: 12500 is $125.00. */
735
+ hourly_coaching_cents: number;
736
+ booking_hours: string;
737
+ /** In cents: 12500 is $125.00. */
738
+ booking_cents: number;
739
+ /** In cents: 12500 is $125.00. */
740
+ bonus_cents: number;
741
+ /** Salary, hourly coaching wages, and bonus. In cents: 12500 is $125.00. */
742
+ total_pay_cents: number;
743
+ /** The year's payroll tax on the total pay. In cents: 12500 is $125.00. */
744
+ payroll_tax_cents: number;
745
+ }
746
+ /** The other saved versions a version's results were worked out with. Worked out when the version was saved, and never changed. */
747
+ export interface PlanResultVersions {
748
+ id: string;
749
+ /** The saved version these results are for. */
750
+ version_id: string | PlanVersions;
751
+ /** Another saved version in the club's plan when this one was saved. */
752
+ calculated_with_id: string | PlanVersions;
753
+ }
754
+ /** What the P&L warned about when a version was saved. Worked out when the version was saved, and never changed. */
755
+ export interface PlanResultWarnings {
756
+ id: string;
757
+ /** The saved version these results are for. */
758
+ version_id: string | PlanVersions;
759
+ /** The kind of warning. */
760
+ code: string;
761
+ message: string;
762
+ subject_key: string | null;
763
+ }
764
+ /** A saved version's club-wide totals. Worked out when the version was saved, and never changed. */
765
+ export interface PlanResults {
766
+ id: string;
767
+ /** The saved version these results are for. */
768
+ version_id: string | PlanVersions;
769
+ calculated_at: string;
770
+ /** In cents: 12500 is $125.00. */
771
+ operating_revenue_cents: number;
772
+ /** In cents: 12500 is $125.00. */
773
+ operating_expense_cents: number;
774
+ /** In cents: 12500 is $125.00. */
775
+ operating_net_cents: number;
776
+ /** In cents: 12500 is $125.00. */
777
+ fundraising_revenue_cents: number;
778
+ /** In cents: 12500 is $125.00. */
779
+ fundraising_expense_cents: number;
780
+ /** In cents: 12500 is $125.00. */
781
+ fundraising_net_cents: number;
782
+ /** Spent on capital, such as a coach boat. In cents: 12500 is $125.00. */
783
+ capital_cents: number;
784
+ /** Operating net, plus fundraising net, less capital. In cents: 12500 is $125.00. */
785
+ total_cents: number;
786
+ }
659
787
  /** A charge set as a share of each offering's revenue, such as scholarships or card fees. Operations Plan only. */
660
788
  export interface PlanRevenueCharges {
661
789
  id: string;
@@ -756,6 +884,8 @@ export interface PlanVersions {
756
884
  operations_version_id: string | PlanVersions | null;
757
885
  /** What you want that the plan's numbers don't capture, for your AI to fill in. For example: every Monday, Wednesday and Friday, except on SPS school holidays, from 4pm until dusk. */
758
886
  rules: string | null;
887
+ /** What the P&L showed when this version was saved, worked out a moment after it's saved. A saved version with none means working them out failed, and an admin can fill them in. */
888
+ results: (string | PlanResults)[];
759
889
  }
760
890
  /** One part of the club's plan: a program area's plan, or the Operations Plan. */
761
891
  export interface Plans {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cyc-seattle/platform",
3
- "version": "1.20.1",
3
+ "version": "1.22.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",
@@ -78,13 +78,16 @@
78
78
  "@pulumi/pulumi": "3.273.0",
79
79
  "@pulumi/random": "4.21.4",
80
80
  "@types/js-yaml": "4.0.9",
81
+ "@types/luxon": "^3.7.6",
81
82
  "@types/node": "^22.19.1",
82
83
  "@types/parse": "^3.0.9",
83
84
  "esbuild": "0.28.2",
84
85
  "eslint": "^9.39.5",
85
86
  "google-auth-library": "^11.3.0",
87
+ "google-spreadsheet": "^5.3.0",
86
88
  "googleapis": "^144.0.0",
87
89
  "js-yaml": "^5.4.3",
90
+ "luxon": "^3.7.2",
88
91
  "prettier": "^3.9.10",
89
92
  "typescript": "~5.9.3",
90
93
  "vitest": "^5.0.0",