placementt-core 1.400.1183 → 1.400.1184

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,9 +1,17 @@
1
- import { ExternalEvent } from "./typeDefinitions";
2
- export type EventTimeSlot = {
3
- id: string;
1
+ import { EventTimeSlotSetting, ExternalEvent } from "./typeDefinitions";
2
+ export type EventTimeSlot = EventTimeSlotSetting & {
4
3
  date: string;
5
- start: string;
6
- end: string;
7
4
  };
8
- export declare const getEventTimeSlots: (event: Pick<ExternalEvent, "timeSlots" | "startDate" | "endDate" | "startTime" | "endTime">) => EventTimeSlot[];
9
- export declare const getSlotPlacesLeft: (event: Pick<ExternalEvent, "timeSlots" | "slotBookings">, slotId: string) => number;
5
+ export declare const getEventDateRange: (event: Pick<ExternalEvent, "startDate" | "endDate">) => string[];
6
+ export declare const getEventTimeSlots: (event: Pick<ExternalEvent, "timeSlots" | "startDate" | "endDate">) => EventTimeSlot[];
7
+ export declare const getSlotPlacesLeft: (event: Pick<ExternalEvent, "timeSlots" | "slotBookings" | "startDate" | "endDate">, slotId: string) => number;
8
+ export type SlotBookingChange = {
9
+ slotId: string;
10
+ before: number;
11
+ after: number;
12
+ };
13
+ export declare const getSlotBookingChanges: (before?: {
14
+ [slotId: string]: number;
15
+ }, after?: {
16
+ [slotId: string]: number;
17
+ }) => SlotBookingChange[];
@@ -1,33 +1,42 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.getSlotPlacesLeft = exports.getEventTimeSlots = void 0;
4
- const toMinutes = (time) => {
5
- const [h, m] = time.split(":").map((v) => parseInt(v));
6
- return h * 60 + (m || 0);
7
- };
8
- const toTime = (minutes) => `${String(Math.floor(minutes / 60)).padStart(2, "0")}:${String(minutes % 60).padStart(2, "0")}`;
9
- // Every slot for the event, one run of slotMinutes-long slots per day between
10
- // startDate and endDate (inclusive). A final slot that would overrun endTime is dropped.
11
- const getEventTimeSlots = (event) => {
12
- const { timeSlots, startDate, startTime, endTime } = event;
13
- if (!timeSlots?.slotMinutes || !startDate || !startTime || !endTime)
3
+ exports.getSlotBookingChanges = exports.getSlotPlacesLeft = exports.getEventTimeSlots = exports.getEventDateRange = void 0;
4
+ // Every date from startDate to endDate (inclusive), as "YYYY-MM-DD".
5
+ const getEventDateRange = (event) => {
6
+ if (!event.startDate)
14
7
  return [];
15
- const dayStart = toMinutes(startTime);
16
- const dayEnd = toMinutes(endTime);
17
- const first = new Date(`${startDate.slice(0, 10)}T00:00:00Z`);
18
- const last = new Date(`${(event.endDate || startDate).slice(0, 10)}T00:00:00Z`);
8
+ const first = new Date(`${event.startDate.slice(0, 10)}T00:00:00Z`);
9
+ const last = new Date(`${(event.endDate || event.startDate).slice(0, 10)}T00:00:00Z`);
19
10
  if (isNaN(first.getTime()) || isNaN(last.getTime()))
20
11
  return [];
21
- const slots = [];
12
+ const dates = [];
22
13
  for (const day = first; day <= last; day.setUTCDate(day.getUTCDate() + 1)) {
23
- const date = day.toISOString().slice(0, 10);
24
- for (let t = dayStart; t + timeSlots.slotMinutes <= dayEnd; t += timeSlots.slotMinutes) {
25
- slots.push({ id: `${date}T${toTime(t)}`, date, start: toTime(t), end: toTime(t + timeSlots.slotMinutes) });
26
- }
14
+ dates.push(day.toISOString().slice(0, 10));
27
15
  }
28
- return slots;
16
+ return dates;
17
+ };
18
+ exports.getEventDateRange = getEventDateRange;
19
+ // Every bookable slot, in date then start time order. Slots set up for a date that is no
20
+ // longer in the event's date range are left out, as are incomplete rows.
21
+ const getEventTimeSlots = (event) => {
22
+ const days = event.timeSlots?.days;
23
+ if (!days)
24
+ return [];
25
+ return (0, exports.getEventDateRange)(event).flatMap((date) => (days[date] || [])
26
+ .filter((slot) => slot.id && slot.start && slot.end && slot.capacity > 0)
27
+ .map((slot) => ({ ...slot, date }))
28
+ .sort((a, b) => a.start.localeCompare(b.start)));
29
29
  };
30
30
  exports.getEventTimeSlots = getEventTimeSlots;
31
- const getSlotPlacesLeft = (event, slotId) => Math.max(0, (event.timeSlots?.capacity || 0) - (event.slotBookings?.[slotId] || 0));
31
+ const getSlotPlacesLeft = (event, slotId) => {
32
+ const slot = (0, exports.getEventTimeSlots)(event).find((s) => s.id === slotId);
33
+ return Math.max(0, (slot?.capacity || 0) - (event.slotBookings?.[slotId] || 0));
34
+ };
32
35
  exports.getSlotPlacesLeft = getSlotPlacesLeft;
36
+ // What changed between two versions of a school's booking, in slot order where the slots still exist.
37
+ const getSlotBookingChanges = (before = {}, after = {}) => [...new Set([...Object.keys(before), ...Object.keys(after)])]
38
+ .map((slotId) => ({ slotId, before: before[slotId] || 0, after: after[slotId] || 0 }))
39
+ .filter((c) => c.before !== c.after)
40
+ .sort((a, b) => a.slotId.localeCompare(b.slotId));
41
+ exports.getSlotBookingChanges = getSlotBookingChanges;
33
42
  //# sourceMappingURL=eventTimeSlots.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"eventTimeSlots.js","sourceRoot":"","sources":["../src/eventTimeSlots.ts"],"names":[],"mappings":";;;AASA,MAAM,SAAS,GAAG,CAAC,IAAY,EAAE,EAAE;IAC/B,MAAM,CAAC,CAAC,EAAE,CAAC,CAAC,GAAG,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,QAAQ,CAAC,CAAC,CAAC,CAAC,CAAC;IACvD,OAAO,CAAC,GAAG,EAAE,GAAG,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC;AAC7B,CAAC,CAAC;AAEF,MAAM,MAAM,GAAG,CAAC,OAAe,EAAE,EAAE,CAAC,GAAG,MAAM,CAAC,IAAI,CAAC,KAAK,CAAC,OAAO,GAAG,EAAE,CAAC,CAAC,CAAC,QAAQ,CAAC,CAAC,EAAE,GAAG,CAAC,IAAI,MAAM,CAAC,OAAO,GAAG,EAAE,CAAC,CAAC,QAAQ,CAAC,CAAC,EAAE,GAAG,CAAC,EAAE,CAAC;AAEpI,8EAA8E;AAC9E,yFAAyF;AAClF,MAAM,iBAAiB,GAAG,CAAC,KAAmF,EAAmB,EAAE;IACtI,MAAM,EAAC,SAAS,EAAE,SAAS,EAAE,SAAS,EAAE,OAAO,EAAC,GAAG,KAAK,CAAC;IACzD,IAAI,CAAC,SAAS,EAAE,WAAW,IAAI,CAAC,SAAS,IAAI,CAAC,SAAS,IAAI,CAAC,OAAO;QAAE,OAAO,EAAE,CAAC;IAE/E,MAAM,QAAQ,GAAG,SAAS,CAAC,SAAS,CAAC,CAAC;IACtC,MAAM,MAAM,GAAG,SAAS,CAAC,OAAO,CAAC,CAAC;IAClC,MAAM,KAAK,GAAG,IAAI,IAAI,CAAC,GAAG,SAAS,CAAC,KAAK,CAAC,CAAC,EAAE,EAAE,CAAC,YAAY,CAAC,CAAC;IAC9D,MAAM,IAAI,GAAG,IAAI,IAAI,CAAC,GAAG,CAAC,KAAK,CAAC,OAAO,IAAI,SAAS,CAAC,CAAC,KAAK,CAAC,CAAC,EAAE,EAAE,CAAC,YAAY,CAAC,CAAC;IAChF,IAAI,KAAK,CAAC,KAAK,CAAC,OAAO,EAAE,CAAC,IAAI,KAAK,CAAC,IAAI,CAAC,OAAO,EAAE,CAAC;QAAE,OAAO,EAAE,CAAC;IAE/D,MAAM,KAAK,GAAoB,EAAE,CAAC;IAClC,KAAK,MAAM,GAAG,GAAG,KAAK,EAAE,GAAG,IAAI,IAAI,EAAE,GAAG,CAAC,UAAU,CAAC,GAAG,CAAC,UAAU,EAAE,GAAG,CAAC,CAAC,EAAE,CAAC;QACxE,MAAM,IAAI,GAAG,GAAG,CAAC,WAAW,EAAE,CAAC,KAAK,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC;QAC5C,KAAK,IAAI,CAAC,GAAG,QAAQ,EAAE,CAAC,GAAG,SAAS,CAAC,WAAW,IAAI,MAAM,EAAE,CAAC,IAAI,SAAS,CAAC,WAAW,EAAE,CAAC;YACrF,KAAK,CAAC,IAAI,CAAC,EAAC,EAAE,EAAE,GAAG,IAAI,IAAI,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,IAAI,EAAE,KAAK,EAAE,MAAM,CAAC,CAAC,CAAC,EAAE,GAAG,EAAE,MAAM,CAAC,CAAC,GAAG,SAAS,CAAC,WAAW,CAAC,EAAC,CAAC,CAAC;QAC7G,CAAC;IACL,CAAC;IACD,OAAO,KAAK,CAAC;AACjB,CAAC,CAAC;AAlBW,QAAA,iBAAiB,qBAkB5B;AAEK,MAAM,iBAAiB,GAAG,CAAC,KAAsD,EAAE,MAAc,EAAE,EAAE,CACxG,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,CAAC,KAAK,CAAC,SAAS,EAAE,QAAQ,IAAI,CAAC,CAAC,GAAG,CAAC,KAAK,CAAC,YAAY,EAAE,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;AAD3E,QAAA,iBAAiB,qBAC0D"}
1
+ {"version":3,"file":"eventTimeSlots.js","sourceRoot":"","sources":["../src/eventTimeSlots.ts"],"names":[],"mappings":"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"}
@@ -1,29 +1,58 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  const eventTimeSlots_1 = require("./eventTimeSlots");
4
+ const slot = (id, start, end, capacity = 30) => ({ id, start, end, capacity });
5
+ describe("getEventDateRange", () => {
6
+ it("lists every date in the range", () => {
7
+ expect((0, eventTimeSlots_1.getEventDateRange)({ startDate: "2026-11-02", endDate: "2026-11-04" })).toEqual(["2026-11-02", "2026-11-03", "2026-11-04"]);
8
+ });
9
+ it("uses the start date alone for a single-day event", () => {
10
+ expect((0, eventTimeSlots_1.getEventDateRange)({ startDate: "2026-11-02" })).toEqual(["2026-11-02"]);
11
+ expect((0, eventTimeSlots_1.getEventDateRange)({})).toEqual([]);
12
+ });
13
+ });
4
14
  describe("getEventTimeSlots", () => {
5
- const timeSlots = { slotMinutes: 60, capacity: 30, capacityUnit: "students", maxSlotsPerSchool: 1 };
6
- it("generates hourly slots for each day in the range", () => {
7
- const slots = (0, eventTimeSlots_1.getEventTimeSlots)({ timeSlots, startDate: "2026-11-02", endDate: "2026-11-04", startTime: "09:00", endTime: "12:00" });
8
- expect(slots).toHaveLength(9);
9
- expect(slots[0]).toEqual({ id: "2026-11-02T09:00", date: "2026-11-02", start: "09:00", end: "10:00" });
10
- expect(slots[8].id).toBe("2026-11-04T11:00");
15
+ const timeSlots = {
16
+ capacityUnit: "students",
17
+ days: {
18
+ "2026-11-02": [slot("b", "11:00", "12:00"), slot("a", "09:00", "10:00", 20)],
19
+ "2026-11-03": [slot("c", "13:00", "14:00"), slot("incomplete", "", "14:00")],
20
+ "2026-11-09": [slot("outOfRange", "09:00", "10:00")],
21
+ },
22
+ };
23
+ it("returns slots in date and start time order, with the date attached", () => {
24
+ const slots = (0, eventTimeSlots_1.getEventTimeSlots)({ timeSlots, startDate: "2026-11-02", endDate: "2026-11-03" });
25
+ expect(slots.map((s) => s.id)).toEqual(["a", "b", "c"]);
26
+ expect(slots[0]).toEqual({ id: "a", start: "09:00", end: "10:00", capacity: 20, date: "2026-11-02" });
11
27
  });
12
- it("drops a final slot that would overrun the end time", () => {
13
- const slots = (0, eventTimeSlots_1.getEventTimeSlots)({ timeSlots: { ...timeSlots, slotMinutes: 45 }, startDate: "2026-11-02", startTime: "09:00", endTime: "10:30" });
14
- expect(slots.map((s) => s.id)).toEqual(["2026-11-02T09:00", "2026-11-02T09:45"]);
28
+ it("leaves out dates outside the event range and incomplete rows", () => {
29
+ const ids = (0, eventTimeSlots_1.getEventTimeSlots)({ timeSlots, startDate: "2026-11-02", endDate: "2026-11-03" }).map((s) => s.id);
30
+ expect(ids).not.toContain("outOfRange");
31
+ expect(ids).not.toContain("incomplete");
15
32
  });
16
- it("returns nothing without slot settings or times", () => {
17
- expect((0, eventTimeSlots_1.getEventTimeSlots)({ startDate: "2026-11-02", startTime: "09:00", endTime: "12:00" })).toEqual([]);
18
- expect((0, eventTimeSlots_1.getEventTimeSlots)({ timeSlots, startDate: "2026-11-02" })).toEqual([]);
33
+ it("returns nothing without slot settings", () => {
34
+ expect((0, eventTimeSlots_1.getEventTimeSlots)({ startDate: "2026-11-02" })).toEqual([]);
19
35
  });
20
36
  });
21
37
  describe("getSlotPlacesLeft", () => {
22
- it("subtracts bookings from capacity and never goes negative", () => {
23
- const timeSlots = { slotMinutes: 60, capacity: 30, capacityUnit: "students", maxSlotsPerSchool: 1 };
24
- expect((0, eventTimeSlots_1.getSlotPlacesLeft)({ timeSlots, slotBookings: { a: 12 } }, "a")).toBe(18);
25
- expect((0, eventTimeSlots_1.getSlotPlacesLeft)({ timeSlots, slotBookings: { a: 40 } }, "a")).toBe(0);
26
- expect((0, eventTimeSlots_1.getSlotPlacesLeft)({ timeSlots }, "b")).toBe(30);
38
+ const event = { startDate: "2026-11-02", timeSlots: { capacityUnit: "students", days: { "2026-11-02": [slot("a", "09:00", "10:00", 30)] } } };
39
+ it("subtracts bookings from the slot's own capacity and never goes negative", () => {
40
+ expect((0, eventTimeSlots_1.getSlotPlacesLeft)({ ...event, slotBookings: { a: 12 } }, "a")).toBe(18);
41
+ expect((0, eventTimeSlots_1.getSlotPlacesLeft)({ ...event, slotBookings: { a: 40 } }, "a")).toBe(0);
42
+ expect((0, eventTimeSlots_1.getSlotPlacesLeft)(event, "missing")).toBe(0);
43
+ });
44
+ });
45
+ describe("getSlotBookingChanges", () => {
46
+ it("lists added, removed and resized slots", () => {
47
+ expect((0, eventTimeSlots_1.getSlotBookingChanges)({ a: 10, b: 5, c: 3 }, { a: 10, b: 8, d: 2 })).toEqual([
48
+ { slotId: "b", before: 5, after: 8 },
49
+ { slotId: "c", before: 3, after: 0 },
50
+ { slotId: "d", before: 0, after: 2 },
51
+ ]);
52
+ });
53
+ it("returns nothing when the booking is unchanged", () => {
54
+ expect((0, eventTimeSlots_1.getSlotBookingChanges)({ a: 10 }, { a: 10 })).toEqual([]);
55
+ expect((0, eventTimeSlots_1.getSlotBookingChanges)()).toEqual([]);
27
56
  });
28
57
  });
29
58
  //# sourceMappingURL=eventTimeSlots.test.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"eventTimeSlots.test.js","sourceRoot":"","sources":["../src/eventTimeSlots.test.ts"],"names":[],"mappings":";;AAAA,qDAAsE;AAEtE,QAAQ,CAAC,mBAAmB,EAAE,GAAG,EAAE;IAC/B,MAAM,SAAS,GAAG,EAAC,WAAW,EAAE,EAAE,EAAE,QAAQ,EAAE,EAAE,EAAE,YAAY,EAAE,UAAmB,EAAE,iBAAiB,EAAE,CAAC,EAAC,CAAC;IAE3G,EAAE,CAAC,kDAAkD,EAAE,GAAG,EAAE;QACxD,MAAM,KAAK,GAAG,IAAA,kCAAiB,EAAC,EAAC,SAAS,EAAE,SAAS,EAAE,YAAY,EAAE,OAAO,EAAE,YAAY,EAAE,SAAS,EAAE,OAAO,EAAE,OAAO,EAAE,OAAO,EAAC,CAAC,CAAC;QACnI,MAAM,CAAC,KAAK,CAAC,CAAC,YAAY,CAAC,CAAC,CAAC,CAAC;QAC9B,MAAM,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,EAAC,EAAE,EAAE,kBAAkB,EAAE,IAAI,EAAE,YAAY,EAAE,KAAK,EAAE,OAAO,EAAE,GAAG,EAAE,OAAO,EAAC,CAAC,CAAC;QACrG,MAAM,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,IAAI,CAAC,kBAAkB,CAAC,CAAC;IACjD,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,oDAAoD,EAAE,GAAG,EAAE;QAC1D,MAAM,KAAK,GAAG,IAAA,kCAAiB,EAAC,EAAC,SAAS,EAAE,EAAC,GAAG,SAAS,EAAE,WAAW,EAAE,EAAE,EAAC,EAAE,SAAS,EAAE,YAAY,EAAE,SAAS,EAAE,OAAO,EAAE,OAAO,EAAE,OAAO,EAAC,CAAC,CAAC;QAC7I,MAAM,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,kBAAkB,EAAE,kBAAkB,CAAC,CAAC,CAAC;IACrF,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,gDAAgD,EAAE,GAAG,EAAE;QACtD,MAAM,CAAC,IAAA,kCAAiB,EAAC,EAAC,SAAS,EAAE,YAAY,EAAE,SAAS,EAAE,OAAO,EAAE,OAAO,EAAE,OAAO,EAAC,CAAC,CAAC,CAAC,OAAO,CAAC,EAAE,CAAC,CAAC;QACvG,MAAM,CAAC,IAAA,kCAAiB,EAAC,EAAC,SAAS,EAAE,SAAS,EAAE,YAAY,EAAC,CAAC,CAAC,CAAC,OAAO,CAAC,EAAE,CAAC,CAAC;IAChF,CAAC,CAAC,CAAC;AACP,CAAC,CAAC,CAAC;AAEH,QAAQ,CAAC,mBAAmB,EAAE,GAAG,EAAE;IAC/B,EAAE,CAAC,0DAA0D,EAAE,GAAG,EAAE;QAChE,MAAM,SAAS,GAAG,EAAC,WAAW,EAAE,EAAE,EAAE,QAAQ,EAAE,EAAE,EAAE,YAAY,EAAE,UAAmB,EAAE,iBAAiB,EAAE,CAAC,EAAC,CAAC;QAC3G,MAAM,CAAC,IAAA,kCAAiB,EAAC,EAAC,SAAS,EAAE,YAAY,EAAE,EAAC,CAAC,EAAE,EAAE,EAAC,EAAC,EAAE,GAAG,CAAC,CAAC,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC;QAC5E,MAAM,CAAC,IAAA,kCAAiB,EAAC,EAAC,SAAS,EAAE,YAAY,EAAE,EAAC,CAAC,EAAE,EAAE,EAAC,EAAC,EAAE,GAAG,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;QAC3E,MAAM,CAAC,IAAA,kCAAiB,EAAC,EAAC,SAAS,EAAC,EAAE,GAAG,CAAC,CAAC,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC;IACzD,CAAC,CAAC,CAAC;AACP,CAAC,CAAC,CAAC"}
1
+ {"version":3,"file":"eventTimeSlots.test.js","sourceRoot":"","sources":["../src/eventTimeSlots.test.ts"],"names":[],"mappings":"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"}
@@ -2398,10 +2398,10 @@ export type ExternalEvent = {
2398
2398
  maxSchools?: number;
2399
2399
  maxStudents?: number;
2400
2400
  timeSlots?: {
2401
- slotMinutes: number;
2402
- capacity: number;
2403
2401
  capacityUnit: "students" | "schools";
2404
- maxSlotsPerSchool: number;
2402
+ days?: {
2403
+ [date: string]: EventTimeSlotSetting[];
2404
+ };
2405
2405
  };
2406
2406
  slotBookings?: {
2407
2407
  [slotId: string]: number;
@@ -3388,6 +3388,12 @@ export type ExternalEventAttendee = {
3388
3388
  };
3389
3389
  };
3390
3390
  };
3391
+ export type EventTimeSlotSetting = {
3392
+ id: string;
3393
+ start: string;
3394
+ end: string;
3395
+ capacity: number;
3396
+ };
3391
3397
  export type ExternalEventCareersSchool = {
3392
3398
  oId: string;
3393
3399
  instituteId: string;
@@ -1 +1 @@
1
- {"version":3,"file":"typeDefinitions.js","sourceRoot":"","sources":["../src/typeDefinitions.ts"],"names":[],"mappings":";;;AAypIA,sDASC;AAeD,oDAKC;AAaD,0CAEC;AA5oED;;;;;kFAKkF;AACrE,QAAA,qBAAqB,GAAG,SAAS,CAAC;AAClC,QAAA,iBAAiB,GAAG,OAAO,CAAC;AAwjEzC;yEACyE;AAC5D,QAAA,0BAA0B,GAA2C;IAC9E,gBAAgB,EAAE,2BAA2B;IAC7C,mBAAmB,EAAE,sBAAsB;IAC3C,cAAc,EAAE,iBAAiB;IACjC,kBAAkB,EAAE,qBAAqB;IACzC,UAAU,EAAE,oCAAoC;IAChD,aAAa,EAAE,yBAAyB;IACxC,aAAa,EAAE,wBAAwB;IACvC,KAAK,EAAE,OAAO;CACjB,CAAC;AAEW,QAAA,0BAA0B,GAA+C;IAClF,QAAQ,EAAE,WAAW;IACrB,KAAK,EAAE,YAAY;IACnB,KAAK,EAAE,OAAO;CACjB,CAAC;AAEF;;;;;;;;;;;;;GAaG;AACH,SAAgB,qBAAqB,CAAC,SAAsC;IACxE,QAAQ,SAAS,EAAE,CAAC;QACpB,KAAK,MAAM,CAAC,CAAC,OAAO,OAAO,CAAC;QAC5B,KAAK,OAAO,CAAC,CAAC,OAAO,OAAO,CAAC;QAC7B,KAAK,WAAW,CAAC;QACjB,KAAK,OAAO,CAAC;QACb,KAAK,OAAO,CAAC;QACb,OAAO,CAAC,CAAC,OAAO,UAAU,CAAC;IAC3B,CAAC;AACL,CAAC;AAED;;;;;;;;;;;;GAYG;AACH,SAAgB,oBAAoB,CAChC,IAAwC,EACxC,MAA6C;IAE7C,OAAO,IAAI,EAAE,WAAW,IAAI,MAAM,EAAE,YAAY,IAAI,KAAK,CAAC;AAC9D,CAAC;AAED;;;;;;;;;;GAUG;AACH,SAAgB,eAAe,CAAC,KAA6D;IACzF,OAAO,GAAG,KAAK,CAAC,cAAc,IAAI,EAAE,IAAI,KAAK,CAAC,aAAa,IAAI,EAAE,EAAE,CAAC,IAAI,EAAE,CAAC;AAC/E,CAAC"}
1
+ {"version":3,"file":"typeDefinitions.js","sourceRoot":"","sources":["../src/typeDefinitions.ts"],"names":[],"mappings":";;;AA+pIA,sDASC;AAeD,oDAKC;AAaD,0CAEC;AAlpED;;;;;kFAKkF;AACrE,QAAA,qBAAqB,GAAG,SAAS,CAAC;AAClC,QAAA,iBAAiB,GAAG,OAAO,CAAC;AA8jEzC;yEACyE;AAC5D,QAAA,0BAA0B,GAA2C;IAC9E,gBAAgB,EAAE,2BAA2B;IAC7C,mBAAmB,EAAE,sBAAsB;IAC3C,cAAc,EAAE,iBAAiB;IACjC,kBAAkB,EAAE,qBAAqB;IACzC,UAAU,EAAE,oCAAoC;IAChD,aAAa,EAAE,yBAAyB;IACxC,aAAa,EAAE,wBAAwB;IACvC,KAAK,EAAE,OAAO;CACjB,CAAC;AAEW,QAAA,0BAA0B,GAA+C;IAClF,QAAQ,EAAE,WAAW;IACrB,KAAK,EAAE,YAAY;IACnB,KAAK,EAAE,OAAO;CACjB,CAAC;AAEF;;;;;;;;;;;;;GAaG;AACH,SAAgB,qBAAqB,CAAC,SAAsC;IACxE,QAAQ,SAAS,EAAE,CAAC;QACpB,KAAK,MAAM,CAAC,CAAC,OAAO,OAAO,CAAC;QAC5B,KAAK,OAAO,CAAC,CAAC,OAAO,OAAO,CAAC;QAC7B,KAAK,WAAW,CAAC;QACjB,KAAK,OAAO,CAAC;QACb,KAAK,OAAO,CAAC;QACb,OAAO,CAAC,CAAC,OAAO,UAAU,CAAC;IAC3B,CAAC;AACL,CAAC;AAED;;;;;;;;;;;;GAYG;AACH,SAAgB,oBAAoB,CAChC,IAAwC,EACxC,MAA6C;IAE7C,OAAO,IAAI,EAAE,WAAW,IAAI,MAAM,EAAE,YAAY,IAAI,KAAK,CAAC;AAC9D,CAAC;AAED;;;;;;;;;;GAUG;AACH,SAAgB,eAAe,CAAC,KAA6D;IACzF,OAAO,GAAG,KAAK,CAAC,cAAc,IAAI,EAAE,IAAI,KAAK,CAAC,aAAa,IAAI,EAAE,EAAE,CAAC,IAAI,EAAE,CAAC;AAC/E,CAAC"}
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "private": false,
3
3
  "name": "placementt-core",
4
4
  "author": "Placementt",
5
- "version": "1.400.1183",
5
+ "version": "1.400.1184",
6
6
  "main": "lib/index.js",
7
7
  "types": "lib/index.d.ts",
8
8
  "scripts": {
@@ -1,31 +1,66 @@
1
- import {getEventTimeSlots, getSlotPlacesLeft} from "./eventTimeSlots";
1
+ import {getEventDateRange, getEventTimeSlots, getSlotBookingChanges, getSlotPlacesLeft} from "./eventTimeSlots";
2
+
3
+ const slot = (id: string, start: string, end: string, capacity = 30) => ({id, start, end, capacity});
4
+
5
+ describe("getEventDateRange", () => {
6
+ it("lists every date in the range", () => {
7
+ expect(getEventDateRange({startDate: "2026-11-02", endDate: "2026-11-04"})).toEqual(["2026-11-02", "2026-11-03", "2026-11-04"]);
8
+ });
9
+
10
+ it("uses the start date alone for a single-day event", () => {
11
+ expect(getEventDateRange({startDate: "2026-11-02"})).toEqual(["2026-11-02"]);
12
+ expect(getEventDateRange({})).toEqual([]);
13
+ });
14
+ });
2
15
 
3
16
  describe("getEventTimeSlots", () => {
4
- const timeSlots = {slotMinutes: 60, capacity: 30, capacityUnit: "students" as const, maxSlotsPerSchool: 1};
17
+ const timeSlots = {
18
+ capacityUnit: "students" as const,
19
+ days: {
20
+ "2026-11-02": [slot("b", "11:00", "12:00"), slot("a", "09:00", "10:00", 20)],
21
+ "2026-11-03": [slot("c", "13:00", "14:00"), slot("incomplete", "", "14:00")],
22
+ "2026-11-09": [slot("outOfRange", "09:00", "10:00")],
23
+ },
24
+ };
5
25
 
6
- it("generates hourly slots for each day in the range", () => {
7
- const slots = getEventTimeSlots({timeSlots, startDate: "2026-11-02", endDate: "2026-11-04", startTime: "09:00", endTime: "12:00"});
8
- expect(slots).toHaveLength(9);
9
- expect(slots[0]).toEqual({id: "2026-11-02T09:00", date: "2026-11-02", start: "09:00", end: "10:00"});
10
- expect(slots[8].id).toBe("2026-11-04T11:00");
26
+ it("returns slots in date and start time order, with the date attached", () => {
27
+ const slots = getEventTimeSlots({timeSlots, startDate: "2026-11-02", endDate: "2026-11-03"});
28
+ expect(slots.map((s) => s.id)).toEqual(["a", "b", "c"]);
29
+ expect(slots[0]).toEqual({id: "a", start: "09:00", end: "10:00", capacity: 20, date: "2026-11-02"});
11
30
  });
12
31
 
13
- it("drops a final slot that would overrun the end time", () => {
14
- const slots = getEventTimeSlots({timeSlots: {...timeSlots, slotMinutes: 45}, startDate: "2026-11-02", startTime: "09:00", endTime: "10:30"});
15
- expect(slots.map((s) => s.id)).toEqual(["2026-11-02T09:00", "2026-11-02T09:45"]);
32
+ it("leaves out dates outside the event range and incomplete rows", () => {
33
+ const ids = getEventTimeSlots({timeSlots, startDate: "2026-11-02", endDate: "2026-11-03"}).map((s) => s.id);
34
+ expect(ids).not.toContain("outOfRange");
35
+ expect(ids).not.toContain("incomplete");
16
36
  });
17
37
 
18
- it("returns nothing without slot settings or times", () => {
19
- expect(getEventTimeSlots({startDate: "2026-11-02", startTime: "09:00", endTime: "12:00"})).toEqual([]);
20
- expect(getEventTimeSlots({timeSlots, startDate: "2026-11-02"})).toEqual([]);
38
+ it("returns nothing without slot settings", () => {
39
+ expect(getEventTimeSlots({startDate: "2026-11-02"})).toEqual([]);
21
40
  });
22
41
  });
23
42
 
24
43
  describe("getSlotPlacesLeft", () => {
25
- it("subtracts bookings from capacity and never goes negative", () => {
26
- const timeSlots = {slotMinutes: 60, capacity: 30, capacityUnit: "students" as const, maxSlotsPerSchool: 1};
27
- expect(getSlotPlacesLeft({timeSlots, slotBookings: {a: 12}}, "a")).toBe(18);
28
- expect(getSlotPlacesLeft({timeSlots, slotBookings: {a: 40}}, "a")).toBe(0);
29
- expect(getSlotPlacesLeft({timeSlots}, "b")).toBe(30);
44
+ const event = {startDate: "2026-11-02", timeSlots: {capacityUnit: "students" as const, days: {"2026-11-02": [slot("a", "09:00", "10:00", 30)]}}};
45
+
46
+ it("subtracts bookings from the slot's own capacity and never goes negative", () => {
47
+ expect(getSlotPlacesLeft({...event, slotBookings: {a: 12}}, "a")).toBe(18);
48
+ expect(getSlotPlacesLeft({...event, slotBookings: {a: 40}}, "a")).toBe(0);
49
+ expect(getSlotPlacesLeft(event, "missing")).toBe(0);
50
+ });
51
+ });
52
+
53
+ describe("getSlotBookingChanges", () => {
54
+ it("lists added, removed and resized slots", () => {
55
+ expect(getSlotBookingChanges({a: 10, b: 5, c: 3}, {a: 10, b: 8, d: 2})).toEqual([
56
+ {slotId: "b", before: 5, after: 8},
57
+ {slotId: "c", before: 3, after: 0},
58
+ {slotId: "d", before: 0, after: 2},
59
+ ]);
60
+ });
61
+
62
+ it("returns nothing when the booking is unchanged", () => {
63
+ expect(getSlotBookingChanges({a: 10}, {a: 10})).toEqual([]);
64
+ expect(getSlotBookingChanges()).toEqual([]);
30
65
  });
31
66
  });
@@ -1,40 +1,44 @@
1
- import {ExternalEvent} from "./typeDefinitions";
1
+ import {EventTimeSlotSetting, ExternalEvent} from "./typeDefinitions";
2
2
 
3
- export type EventTimeSlot = {
4
- id: string, // "YYYY-MM-DDTHH:mm", stable as long as the event dates and times don't change
3
+ export type EventTimeSlot = EventTimeSlotSetting & {
5
4
  date: string, // "YYYY-MM-DD"
6
- start: string, // "HH:mm"
7
- end: string, // "HH:mm"
8
5
  };
9
6
 
10
- const toMinutes = (time: string) => {
11
- const [h, m] = time.split(":").map((v) => parseInt(v));
12
- return h * 60 + (m || 0);
13
- };
14
-
15
- const toTime = (minutes: number) => `${String(Math.floor(minutes / 60)).padStart(2, "0")}:${String(minutes % 60).padStart(2, "0")}`;
16
-
17
- // Every slot for the event, one run of slotMinutes-long slots per day between
18
- // startDate and endDate (inclusive). A final slot that would overrun endTime is dropped.
19
- export const getEventTimeSlots = (event: Pick<ExternalEvent, "timeSlots"|"startDate"|"endDate"|"startTime"|"endTime">): EventTimeSlot[] => {
20
- const {timeSlots, startDate, startTime, endTime} = event;
21
- if (!timeSlots?.slotMinutes || !startDate || !startTime || !endTime) return [];
22
-
23
- const dayStart = toMinutes(startTime);
24
- const dayEnd = toMinutes(endTime);
25
- const first = new Date(`${startDate.slice(0, 10)}T00:00:00Z`);
26
- const last = new Date(`${(event.endDate || startDate).slice(0, 10)}T00:00:00Z`);
7
+ // Every date from startDate to endDate (inclusive), as "YYYY-MM-DD".
8
+ export const getEventDateRange = (event: Pick<ExternalEvent, "startDate"|"endDate">): string[] => {
9
+ if (!event.startDate) return [];
10
+ const first = new Date(`${event.startDate.slice(0, 10)}T00:00:00Z`);
11
+ const last = new Date(`${(event.endDate || event.startDate).slice(0, 10)}T00:00:00Z`);
27
12
  if (isNaN(first.getTime()) || isNaN(last.getTime())) return [];
28
13
 
29
- const slots: EventTimeSlot[] = [];
14
+ const dates: string[] = [];
30
15
  for (const day = first; day <= last; day.setUTCDate(day.getUTCDate() + 1)) {
31
- const date = day.toISOString().slice(0, 10);
32
- for (let t = dayStart; t + timeSlots.slotMinutes <= dayEnd; t += timeSlots.slotMinutes) {
33
- slots.push({id: `${date}T${toTime(t)}`, date, start: toTime(t), end: toTime(t + timeSlots.slotMinutes)});
34
- }
16
+ dates.push(day.toISOString().slice(0, 10));
35
17
  }
36
- return slots;
18
+ return dates;
37
19
  };
38
20
 
39
- export const getSlotPlacesLeft = (event: Pick<ExternalEvent, "timeSlots"|"slotBookings">, slotId: string) =>
40
- Math.max(0, (event.timeSlots?.capacity || 0) - (event.slotBookings?.[slotId] || 0));
21
+ // Every bookable slot, in date then start time order. Slots set up for a date that is no
22
+ // longer in the event's date range are left out, as are incomplete rows.
23
+ export const getEventTimeSlots = (event: Pick<ExternalEvent, "timeSlots"|"startDate"|"endDate">): EventTimeSlot[] => {
24
+ const days = event.timeSlots?.days;
25
+ if (!days) return [];
26
+ return getEventDateRange(event).flatMap((date) => (days[date] || [])
27
+ .filter((slot) => slot.id && slot.start && slot.end && slot.capacity > 0)
28
+ .map((slot) => ({...slot, date}))
29
+ .sort((a, b) => a.start.localeCompare(b.start)));
30
+ };
31
+
32
+ export const getSlotPlacesLeft = (event: Pick<ExternalEvent, "timeSlots"|"slotBookings"|"startDate"|"endDate">, slotId: string) => {
33
+ const slot = getEventTimeSlots(event).find((s) => s.id === slotId);
34
+ return Math.max(0, (slot?.capacity || 0) - (event.slotBookings?.[slotId] || 0));
35
+ };
36
+
37
+ export type SlotBookingChange = {slotId: string, before: number, after: number}; // 0 = not booked
38
+
39
+ // What changed between two versions of a school's booking, in slot order where the slots still exist.
40
+ export const getSlotBookingChanges = (before: {[slotId: string]: number} = {}, after: {[slotId: string]: number} = {}): SlotBookingChange[] =>
41
+ [...new Set([...Object.keys(before), ...Object.keys(after)])]
42
+ .map((slotId) => ({slotId, before: before[slotId] || 0, after: after[slotId] || 0}))
43
+ .filter((c) => c.before !== c.after)
44
+ .sort((a, b) => a.slotId.localeCompare(b.slotId));
@@ -2407,14 +2407,13 @@ export type ExternalEvent = {
2407
2407
  maxExternalAttendees?: number, // max number of employers
2408
2408
  maxSchools?: number, // max number of employers
2409
2409
  maxStudents?: number, // max number of employers
2410
- // Timed slots: schools book fixed-length slots generated from startDate..endDate
2411
- // and startTime..endTime (see getEventTimeSlots). Booking and freeing places
2412
- // only happens through the events-bookEventSlots / events-removeEventSlotBooking callables.
2410
+ // Timed slots: the hub lists slots for each event date, each with its own capacity.
2411
+ // Only dates between startDate and endDate are offered (see getEventTimeSlots). Booking
2412
+ // and freeing places only happens through the events-bookEventSlots /
2413
+ // events-removeEventSlotBooking callables.
2413
2414
  timeSlots?: {
2414
- slotMinutes: number,
2415
- capacity: number, // places per slot
2416
2415
  capacityUnit: "students"|"schools",
2417
- maxSlotsPerSchool: number,
2416
+ days?: {[date: string]: EventTimeSlotSetting[]}, // keyed "YYYY-MM-DD"
2418
2417
  },
2419
2418
  slotBookings?: {[slotId: string]: number}, // places used per slot
2420
2419
  studentJoinCode: number,
@@ -3376,6 +3375,13 @@ export type ExternalEventAttendee = {
3376
3375
 
3377
3376
 
3378
3377
 
3378
+ export type EventTimeSlotSetting = {
3379
+ id: string, // Kept when the times change, so bookings follow the slot
3380
+ start: string, // "HH:mm"
3381
+ end: string, // "HH:mm"
3382
+ capacity: number, // places in this slot
3383
+ };
3384
+
3379
3385
  export type ExternalEventCareersSchool = {
3380
3386
  oId: string,
3381
3387
  instituteId: string,