placementt-core 1.400.1039 → 1.400.1041

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.
@@ -0,0 +1,140 @@
1
+ import { DestinationCode, DestinationValue, InstituteData, LeaverDestination } from "./typeDefinitions";
2
+ /**
3
+ * destinations.ts — the nine NCCIS codes plus whatever a school has added to them.
4
+ *
5
+ * NCCIS gives every leaver one of nine activity codes, and two of them — NEET and
6
+ * NK — cover situations that could hardly be less alike: a leaver in hospital, one
7
+ * in care, one in custody and one who simply hasn't decided are all the same row of
8
+ * the return. Schools with SEND cohorts need that row broken up, so a school can
9
+ * define its own categories (`InstituteData.leaverDestinations`) and every one of
10
+ * them names the NCCIS code it counts within.
11
+ *
12
+ * That mapping is the whole design: a custom category's id is what gets stored and
13
+ * counted, so the school's own charts get the finer breakdown, while anything
14
+ * statutory (Compass+, the LA MI return) resolves it back to one of the nine.
15
+ */
16
+ export type LeaverDestinationMap = {
17
+ [id: string]: LeaverDestination;
18
+ };
19
+ /**
20
+ * One option as the forms and charts see it — standard and custom side by side, so
21
+ * nothing downstream has to know which it is unless it cares.
22
+ */
23
+ export type DestinationOption = {
24
+ /** What gets stored: an NCCIS code, or a custom category's id. */
25
+ code: DestinationValue;
26
+ label: string;
27
+ /** The NCCIS code this counts within — its own code, for the standard nine. */
28
+ nccisCode: DestinationCode;
29
+ custom: boolean;
30
+ description?: string;
31
+ archived?: boolean;
32
+ };
33
+ /** Ids become Firestore field-path segments (`aggregates.destinations.<id>`), so
34
+ * they may not contain a dot, a slash or anything else a path splits on. */
35
+ export declare const DESTINATION_ID_PATTERN: RegExp;
36
+ /** Custom ids carry a prefix so nothing can shadow a standard code, now or when
37
+ * NCCIS adds one. */
38
+ export declare const CUSTOM_DESTINATION_PREFIX = "custom_";
39
+ /** The NCCIS codes that mean "not in education, employment or training, or we don't
40
+ * know" — the pair schools break down, and the pair a positive-destination rate
41
+ * counts against. */
42
+ export declare const NEGATIVE_DESTINATION_CODES: DestinationCode[];
43
+ /**
44
+ * A stable id for a new custom category, derived from its label.
45
+ *
46
+ * @param {string} label the category's display name.
47
+ * @param {object} [existing] categories already defined, so ids stay unique.
48
+ * @return {string} a field-path-safe id.
49
+ */
50
+ export declare const customDestinationId: (label: string, existing?: LeaverDestinationMap) => string;
51
+ /** The nine NCCIS codes as options, in the order the MI return lists them. */
52
+ export declare const standardDestinationOptions: () => DestinationOption[];
53
+ /**
54
+ * Every destination a school offers: the nine standard codes, then its own
55
+ * categories grouped after the NCCIS code each one belongs to.
56
+ *
57
+ * Archived categories are left out unless asked for — they exist so that historical
58
+ * records keep their label, not so that new leavers can pick them.
59
+ *
60
+ * @param {object} [customs] the institute's leaverDestinations map.
61
+ * @param {object} [opts] `includeArchived` to keep withdrawn categories in the list.
62
+ * @return {DestinationOption[]} the ordered option list.
63
+ */
64
+ export declare const destinationOptions: (customs?: LeaverDestinationMap, opts?: {
65
+ includeArchived?: boolean;
66
+ }) => DestinationOption[];
67
+ /**
68
+ * A school's categories, plus the ones its trust supplies.
69
+ *
70
+ * A trust's categories are extra vocabulary, not a mandate: they are added to
71
+ * whatever the school has of its own, and the school can neither edit nor remove
72
+ * them. Nothing is copied onto the school — resolution is a read-time merge, so a
73
+ * school that leaves the trust simply stops seeing them (see ./trustPolicies for why
74
+ * that is how every trust-supplied setting works here).
75
+ *
76
+ * On an id collision the SCHOOL's entry wins. Both keys are the same, so responses
77
+ * and aggregates land in the same place either way; what differs is that the school
78
+ * keeps the category it made, and keeps being able to edit it.
79
+ *
80
+ * @param {object} [school] the school's own institute doc.
81
+ * @param {object} [trust] the trust's institute doc, when the school is in one.
82
+ * @return {object} the merged map, and the ids the trust owns.
83
+ */
84
+ export declare const resolveLeaverDestinations: (school?: Pick<InstituteData, "leaverDestinations">, trust?: Pick<InstituteData, "leaverDestinations">) => {
85
+ destinations: LeaverDestinationMap;
86
+ trustIds: string[];
87
+ };
88
+ /**
89
+ * What to show for a stored destination — a standard label, or a school's own.
90
+ *
91
+ * A category defined by a DIFFERENT school (a student browsing trust-wide alumni
92
+ * meets these) isn't in the map we hold, so its id is turned back into words rather
93
+ * than shown raw: an id is derived from the label it was created with, which makes
94
+ * "custom_in_hospital" recoverable as "In hospital".
95
+ *
96
+ * @param {string} [code] the stored destination.
97
+ * @param {object} [customs] the institute's leaverDestinations map.
98
+ * @return {string|undefined} the display label.
99
+ */
100
+ export declare const destinationLabel: (code?: DestinationValue, customs?: LeaverDestinationMap) => string | undefined;
101
+ /**
102
+ * The NCCIS code a stored destination counts within. Statutory exports and the
103
+ * NEET/NK figures go through this, never through the raw value.
104
+ *
105
+ * @param {string} [code] the stored destination.
106
+ * @param {object} [customs] the institute's leaverDestinations map.
107
+ * @return {DestinationCode|undefined} the NCCIS code to report it as.
108
+ */
109
+ export declare const destinationNccisCode: (code?: DestinationValue, customs?: LeaverDestinationMap) => DestinationCode | undefined;
110
+ /**
111
+ * Whether a destination is one of the two the school is trying to shrink. Custom
112
+ * categories count exactly as the NCCIS code they name does.
113
+ *
114
+ * @param {string} [code] the stored destination.
115
+ * @param {object} [customs] the institute's leaverDestinations map.
116
+ * @return {boolean} true for NEET / NK and anything mapped to them.
117
+ */
118
+ export declare const isNegativeDestination: (code?: DestinationValue, customs?: LeaverDestinationMap) => boolean;
119
+ /**
120
+ * Re-keys a `{destination: count}` aggregate onto the nine NCCIS codes, folding each
121
+ * custom category into its parent. For anything that has to report in NCCIS terms
122
+ * while the raw aggregate stays as granular as the school made it.
123
+ *
124
+ * @param {object} [counts] the raw aggregate.
125
+ * @param {object} [customs] the institute's leaverDestinations map.
126
+ * @return {object} counts keyed by NCCIS code.
127
+ */
128
+ export declare const rollUpDestinationCounts: (counts?: {
129
+ [code: string]: number;
130
+ }, customs?: LeaverDestinationMap) => {
131
+ [code: string]: number;
132
+ };
133
+ /**
134
+ * Validates a set of custom categories before it is stored. Shared by the setup card
135
+ * and the callable so both refuse the same things.
136
+ *
137
+ * @param {object} destinations the map to check.
138
+ * @return {string|undefined} the first problem found, or undefined if it's valid.
139
+ */
140
+ export declare const validateLeaverDestinations: (destinations: LeaverDestinationMap) => string | undefined;
@@ -0,0 +1,220 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.validateLeaverDestinations = exports.rollUpDestinationCounts = exports.isNegativeDestination = exports.destinationNccisCode = exports.destinationLabel = exports.resolveLeaverDestinations = exports.destinationOptions = exports.standardDestinationOptions = exports.customDestinationId = exports.NEGATIVE_DESTINATION_CODES = exports.CUSTOM_DESTINATION_PREFIX = exports.DESTINATION_ID_PATTERN = void 0;
4
+ const constants_1 = require("./constants");
5
+ /** Ids become Firestore field-path segments (`aggregates.destinations.<id>`), so
6
+ * they may not contain a dot, a slash or anything else a path splits on. */
7
+ exports.DESTINATION_ID_PATTERN = /^[A-Za-z0-9_]{1,60}$/;
8
+ /** Custom ids carry a prefix so nothing can shadow a standard code, now or when
9
+ * NCCIS adds one. */
10
+ exports.CUSTOM_DESTINATION_PREFIX = "custom_";
11
+ /** The NCCIS codes that mean "not in education, employment or training, or we don't
12
+ * know" — the pair schools break down, and the pair a positive-destination rate
13
+ * counts against. */
14
+ exports.NEGATIVE_DESTINATION_CODES = ["NEET", "NK"];
15
+ /**
16
+ * A stable id for a new custom category, derived from its label.
17
+ *
18
+ * @param {string} label the category's display name.
19
+ * @param {object} [existing] categories already defined, so ids stay unique.
20
+ * @return {string} a field-path-safe id.
21
+ */
22
+ const customDestinationId = (label, existing) => {
23
+ const slug = (label || "")
24
+ .trim().toLowerCase()
25
+ .replace(/[^a-z0-9]+/g, "_")
26
+ .replace(/^_+|_+$/g, "")
27
+ .slice(0, 40) || "category";
28
+ const base = `${exports.CUSTOM_DESTINATION_PREFIX}${slug}`;
29
+ if (!existing?.[base])
30
+ return base;
31
+ // Two categories can legitimately slug the same ("In care" / "In-care"), so the
32
+ // second one gets a suffix rather than silently overwriting the first.
33
+ let n = 2;
34
+ while (existing[`${base}_${n}`])
35
+ n++;
36
+ return `${base}_${n}`;
37
+ };
38
+ exports.customDestinationId = customDestinationId;
39
+ /** The nine NCCIS codes as options, in the order the MI return lists them. */
40
+ const standardDestinationOptions = () => Object.entries(constants_1.DESTINATION_CODES).map(([code, label]) => ({
41
+ code, label, nccisCode: code, custom: false,
42
+ }));
43
+ exports.standardDestinationOptions = standardDestinationOptions;
44
+ /**
45
+ * Every destination a school offers: the nine standard codes, then its own
46
+ * categories grouped after the NCCIS code each one belongs to.
47
+ *
48
+ * Archived categories are left out unless asked for — they exist so that historical
49
+ * records keep their label, not so that new leavers can pick them.
50
+ *
51
+ * @param {object} [customs] the institute's leaverDestinations map.
52
+ * @param {object} [opts] `includeArchived` to keep withdrawn categories in the list.
53
+ * @return {DestinationOption[]} the ordered option list.
54
+ */
55
+ const destinationOptions = (customs, opts) => {
56
+ const custom = Object.entries(customs ?? {})
57
+ .filter(([, d]) => d && (opts?.includeArchived || !d.archived))
58
+ .map(([id, d]) => ({
59
+ code: id,
60
+ label: d.label,
61
+ nccisCode: (constants_1.DESTINATION_CODES[d.nccisCode] ? d.nccisCode : "NEET"),
62
+ custom: true,
63
+ description: d.description,
64
+ archived: d.archived,
65
+ }))
66
+ .sort((a, b) => a.label.localeCompare(b.label));
67
+ // Grouped under their parent so "In hospital" reads as a kind of NEET rather than
68
+ // as a tenth code of its own.
69
+ return (0, exports.standardDestinationOptions)().flatMap((std) => [
70
+ std, ...custom.filter((c) => c.nccisCode === std.code),
71
+ ]);
72
+ };
73
+ exports.destinationOptions = destinationOptions;
74
+ /**
75
+ * A school's categories, plus the ones its trust supplies.
76
+ *
77
+ * A trust's categories are extra vocabulary, not a mandate: they are added to
78
+ * whatever the school has of its own, and the school can neither edit nor remove
79
+ * them. Nothing is copied onto the school — resolution is a read-time merge, so a
80
+ * school that leaves the trust simply stops seeing them (see ./trustPolicies for why
81
+ * that is how every trust-supplied setting works here).
82
+ *
83
+ * On an id collision the SCHOOL's entry wins. Both keys are the same, so responses
84
+ * and aggregates land in the same place either way; what differs is that the school
85
+ * keeps the category it made, and keeps being able to edit it.
86
+ *
87
+ * @param {object} [school] the school's own institute doc.
88
+ * @param {object} [trust] the trust's institute doc, when the school is in one.
89
+ * @return {object} the merged map, and the ids the trust owns.
90
+ */
91
+ const resolveLeaverDestinations = (school, trust) => {
92
+ const own = school?.leaverDestinations ?? {};
93
+ const fromTrust = trust?.leaverDestinations ?? {};
94
+ const trustIds = Object.keys(fromTrust).filter((id) => !own[id]);
95
+ return {
96
+ destinations: { ...fromTrust, ...own },
97
+ trustIds,
98
+ };
99
+ };
100
+ exports.resolveLeaverDestinations = resolveLeaverDestinations;
101
+ /**
102
+ * What to show for a stored destination — a standard label, or a school's own.
103
+ *
104
+ * A category defined by a DIFFERENT school (a student browsing trust-wide alumni
105
+ * meets these) isn't in the map we hold, so its id is turned back into words rather
106
+ * than shown raw: an id is derived from the label it was created with, which makes
107
+ * "custom_in_hospital" recoverable as "In hospital".
108
+ *
109
+ * @param {string} [code] the stored destination.
110
+ * @param {object} [customs] the institute's leaverDestinations map.
111
+ * @return {string|undefined} the display label.
112
+ */
113
+ const destinationLabel = (code, customs) => {
114
+ if (!code)
115
+ return undefined;
116
+ if (constants_1.DESTINATION_CODES[code])
117
+ return constants_1.DESTINATION_CODES[code];
118
+ const known = customs?.[code]?.label;
119
+ if (known)
120
+ return known;
121
+ if (!code.startsWith(exports.CUSTOM_DESTINATION_PREFIX))
122
+ return code;
123
+ const words = code.slice(exports.CUSTOM_DESTINATION_PREFIX.length).replace(/_/g, " ").trim();
124
+ return words ? words.charAt(0).toUpperCase() + words.slice(1) : code;
125
+ };
126
+ exports.destinationLabel = destinationLabel;
127
+ /**
128
+ * The NCCIS code a stored destination counts within. Statutory exports and the
129
+ * NEET/NK figures go through this, never through the raw value.
130
+ *
131
+ * @param {string} [code] the stored destination.
132
+ * @param {object} [customs] the institute's leaverDestinations map.
133
+ * @return {DestinationCode|undefined} the NCCIS code to report it as.
134
+ */
135
+ const destinationNccisCode = (code, customs) => {
136
+ if (!code)
137
+ return undefined;
138
+ if (constants_1.DESTINATION_CODES[code])
139
+ return code;
140
+ const custom = customs?.[code];
141
+ // An id we no longer recognise is still somebody's leaver: "not known" is the
142
+ // honest reading, and it keeps them in the cohort rather than dropping them.
143
+ if (!custom)
144
+ return "NK";
145
+ return constants_1.DESTINATION_CODES[custom.nccisCode] ? custom.nccisCode : "NEET";
146
+ };
147
+ exports.destinationNccisCode = destinationNccisCode;
148
+ /**
149
+ * Whether a destination is one of the two the school is trying to shrink. Custom
150
+ * categories count exactly as the NCCIS code they name does.
151
+ *
152
+ * @param {string} [code] the stored destination.
153
+ * @param {object} [customs] the institute's leaverDestinations map.
154
+ * @return {boolean} true for NEET / NK and anything mapped to them.
155
+ */
156
+ const isNegativeDestination = (code, customs) => {
157
+ const nccis = (0, exports.destinationNccisCode)(code, customs);
158
+ return Boolean(nccis && exports.NEGATIVE_DESTINATION_CODES.includes(nccis));
159
+ };
160
+ exports.isNegativeDestination = isNegativeDestination;
161
+ /**
162
+ * Re-keys a `{destination: count}` aggregate onto the nine NCCIS codes, folding each
163
+ * custom category into its parent. For anything that has to report in NCCIS terms
164
+ * while the raw aggregate stays as granular as the school made it.
165
+ *
166
+ * @param {object} [counts] the raw aggregate.
167
+ * @param {object} [customs] the institute's leaverDestinations map.
168
+ * @return {object} counts keyed by NCCIS code.
169
+ */
170
+ const rollUpDestinationCounts = (counts, customs) => {
171
+ const out = {};
172
+ for (const [code, n] of Object.entries(counts ?? {})) {
173
+ const key = (0, exports.destinationNccisCode)(code, customs) ?? "NK";
174
+ out[key] = (out[key] ?? 0) + (n ?? 0);
175
+ }
176
+ return out;
177
+ };
178
+ exports.rollUpDestinationCounts = rollUpDestinationCounts;
179
+ /**
180
+ * Validates a set of custom categories before it is stored. Shared by the setup card
181
+ * and the callable so both refuse the same things.
182
+ *
183
+ * @param {object} destinations the map to check.
184
+ * @return {string|undefined} the first problem found, or undefined if it's valid.
185
+ */
186
+ const validateLeaverDestinations = (destinations) => {
187
+ const entries = Object.entries(destinations ?? {});
188
+ if (entries.length > 50)
189
+ return "You can add up to 50 destination categories.";
190
+ const seen = new Set();
191
+ for (const [id, d] of entries) {
192
+ if (!d)
193
+ return "One of your categories is empty.";
194
+ if (!exports.DESTINATION_ID_PATTERN.test(id) || id !== d.id)
195
+ return "A category id is invalid.";
196
+ if (!id.startsWith(exports.CUSTOM_DESTINATION_PREFIX))
197
+ return "A category id is invalid.";
198
+ if (constants_1.DESTINATION_CODES[id])
199
+ return "A category can't reuse a standard destination code.";
200
+ const label = (d.label ?? "").trim();
201
+ if (!label)
202
+ return "Give every category a name.";
203
+ if (label.length > 60)
204
+ return "Category names must be 60 characters or fewer.";
205
+ if (!constants_1.DESTINATION_CODES[d.nccisCode])
206
+ return `Choose which group "${label}" counts within.`;
207
+ const key = label.toLowerCase();
208
+ if (seen.has(key))
209
+ return `You already have a category called "${label}".`;
210
+ // A category that duplicates a standard label would show twice in the picker
211
+ // with no way to tell them apart.
212
+ if (Object.values(constants_1.DESTINATION_CODES).some((l) => l.toLowerCase() === key)) {
213
+ return `"${label}" is already a standard destination.`;
214
+ }
215
+ seen.add(key);
216
+ }
217
+ return undefined;
218
+ };
219
+ exports.validateLeaverDestinations = validateLeaverDestinations;
220
+ //# sourceMappingURL=destinations.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"destinations.js","sourceRoot":"","sources":["../src/destinations.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,121 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const destinations_1 = require("./destinations");
4
+ const customs = {
5
+ custom_in_hospital: { id: "custom_in_hospital", label: "In hospital", nccisCode: "NEET" },
6
+ custom_in_custody: { id: "custom_in_custody", label: "In custody", nccisCode: "NEET" },
7
+ custom_supported_internship: { id: "custom_supported_internship", label: "Supported internship", nccisCode: "EMP" },
8
+ custom_moved_away: { id: "custom_moved_away", label: "Moved away", nccisCode: "NK", archived: true },
9
+ };
10
+ describe("destinationOptions", () => {
11
+ it("lists the nine NCCIS codes when a school has added nothing", () => {
12
+ const options = (0, destinations_1.destinationOptions)();
13
+ expect(options).toHaveLength(9);
14
+ expect(options.every((o) => !o.custom)).toBe(true);
15
+ });
16
+ it("puts each custom category directly after the code it counts within", () => {
17
+ const codes = (0, destinations_1.destinationOptions)(customs).map((o) => o.code);
18
+ expect(codes.indexOf("custom_supported_internship")).toEqual(codes.indexOf("EMP") + 1);
19
+ // Both NEET categories follow NEET, alphabetically.
20
+ expect(codes.slice(codes.indexOf("NEET"), codes.indexOf("NEET") + 3))
21
+ .toEqual(["NEET", "custom_in_custody", "custom_in_hospital"]);
22
+ });
23
+ it("hides archived categories unless asked for them", () => {
24
+ expect((0, destinations_1.destinationOptions)(customs).map((o) => o.code)).not.toContain("custom_moved_away");
25
+ expect((0, destinations_1.destinationOptions)(customs, { includeArchived: true }).map((o) => o.code))
26
+ .toContain("custom_moved_away");
27
+ });
28
+ });
29
+ describe("resolveLeaverDestinations", () => {
30
+ const trust = { leaverDestinations: {
31
+ custom_in_care: { id: "custom_in_care", label: "In care", nccisCode: "NEET" },
32
+ custom_in_hospital: { id: "custom_in_hospital", label: "In hospital (trust)", nccisCode: "NEET" },
33
+ } };
34
+ it("offers the trust's categories alongside the school's, and names the trust's", () => {
35
+ const { destinations, trustIds } = (0, destinations_1.resolveLeaverDestinations)({ leaverDestinations: customs }, trust);
36
+ expect(Object.keys(destinations).sort()).toEqual([
37
+ "custom_in_care", "custom_in_custody", "custom_in_hospital",
38
+ "custom_moved_away", "custom_supported_internship",
39
+ ]);
40
+ expect(trustIds).toEqual(["custom_in_care"]);
41
+ });
42
+ it("leaves the school's own version of a shared id in the school's hands", () => {
43
+ const { destinations, trustIds } = (0, destinations_1.resolveLeaverDestinations)({ leaverDestinations: customs }, trust);
44
+ expect(destinations.custom_in_hospital.label).toEqual("In hospital");
45
+ expect(trustIds).not.toContain("custom_in_hospital");
46
+ });
47
+ it("is the school's own map when there is no trust", () => {
48
+ expect((0, destinations_1.resolveLeaverDestinations)({ leaverDestinations: customs }).trustIds).toEqual([]);
49
+ expect((0, destinations_1.resolveLeaverDestinations)().destinations).toEqual({});
50
+ });
51
+ });
52
+ describe("destinationLabel", () => {
53
+ it("reads standard codes and the school's own categories", () => {
54
+ expect((0, destinations_1.destinationLabel)("APP")).toEqual("Apprenticeship");
55
+ expect((0, destinations_1.destinationLabel)("custom_in_hospital", customs)).toEqual("In hospital");
56
+ });
57
+ it("recovers words from an id defined by another school", () => {
58
+ // A student browsing trust-wide alumni meets categories we don't hold.
59
+ expect((0, destinations_1.destinationLabel)("custom_in_care")).toEqual("In care");
60
+ });
61
+ });
62
+ describe("destinationNccisCode", () => {
63
+ it("maps a custom category onto the code it reports as", () => {
64
+ expect((0, destinations_1.destinationNccisCode)("custom_in_hospital", customs)).toEqual("NEET");
65
+ expect((0, destinations_1.destinationNccisCode)("custom_supported_internship", customs)).toEqual("EMP");
66
+ });
67
+ it("treats an unknown id as not known rather than dropping the leaver", () => {
68
+ expect((0, destinations_1.destinationNccisCode)("custom_gone", customs)).toEqual("NK");
69
+ });
70
+ it("leaves standard codes alone", () => {
71
+ expect((0, destinations_1.destinationNccisCode)("HE", customs)).toEqual("HE");
72
+ });
73
+ });
74
+ describe("isNegativeDestination", () => {
75
+ it("counts a school's NEET-mapped categories as NEET", () => {
76
+ expect((0, destinations_1.isNegativeDestination)("custom_in_hospital", customs)).toBe(true);
77
+ expect((0, destinations_1.isNegativeDestination)("custom_supported_internship", customs)).toBe(false);
78
+ expect((0, destinations_1.isNegativeDestination)("NK")).toBe(true);
79
+ expect((0, destinations_1.isNegativeDestination)("HE")).toBe(false);
80
+ });
81
+ });
82
+ describe("rollUpDestinationCounts", () => {
83
+ it("folds custom categories into their NCCIS code", () => {
84
+ const rolled = (0, destinations_1.rollUpDestinationCounts)({ HE: 10, NEET: 2, custom_in_hospital: 3, custom_in_custody: 1, custom_supported_internship: 4 }, customs);
85
+ expect(rolled).toEqual({ HE: 10, NEET: 6, EMP: 4 });
86
+ });
87
+ });
88
+ describe("customDestinationId", () => {
89
+ it("derives a field-path-safe id from the label", () => {
90
+ expect((0, destinations_1.customDestinationId)("In hospital")).toEqual("custom_in_hospital");
91
+ });
92
+ it("suffixes rather than overwriting a category that slugs the same", () => {
93
+ expect((0, destinations_1.customDestinationId)("In-hospital", customs)).toEqual("custom_in_hospital_2");
94
+ });
95
+ });
96
+ describe("validateLeaverDestinations", () => {
97
+ it("accepts a well-formed set", () => {
98
+ expect((0, destinations_1.validateLeaverDestinations)(customs)).toBeUndefined();
99
+ });
100
+ it("rejects a category with no name, a bad group, or a duplicate label", () => {
101
+ expect((0, destinations_1.validateLeaverDestinations)({
102
+ custom_x: { id: "custom_x", label: " ", nccisCode: "NEET" },
103
+ })).toBeDefined();
104
+ expect((0, destinations_1.validateLeaverDestinations)({
105
+ custom_x: { id: "custom_x", label: "In hospital", nccisCode: "XX" },
106
+ })).toBeDefined();
107
+ expect((0, destinations_1.validateLeaverDestinations)({
108
+ ...customs,
109
+ custom_in_hospital_2: { id: "custom_in_hospital_2", label: "in hospital", nccisCode: "NEET" },
110
+ })).toBeDefined();
111
+ });
112
+ it("rejects an id that could collide with a standard code or a field path", () => {
113
+ expect((0, destinations_1.validateLeaverDestinations)({
114
+ HE: { id: "HE", label: "Uni", nccisCode: "HE" },
115
+ })).toBeDefined();
116
+ expect((0, destinations_1.validateLeaverDestinations)({
117
+ "custom_a.b": { id: "custom_a.b", label: "Dotted", nccisCode: "NEET" },
118
+ })).toBeDefined();
119
+ });
120
+ });
121
+ //# sourceMappingURL=destinations.test.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"destinations.test.js","sourceRoot":"","sources":["../src/destinations.test.ts"],"names":[],"mappings":"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"}
package/lib/index.d.ts CHANGED
@@ -21,6 +21,7 @@ export * from "./groups";
21
21
  export * from "./emailSections";
22
22
  export * from "./exportTransformers";
23
23
  export * from "./compassExport";
24
+ export * from "./destinations";
24
25
  export * from "./benchmarkEvidenceRules";
25
26
  export * from "./portfolioAggregate";
26
27
  export * from "./tutorialTips";
package/lib/index.js CHANGED
@@ -41,6 +41,7 @@ __exportStar(require("./groups"), exports);
41
41
  __exportStar(require("./emailSections"), exports);
42
42
  __exportStar(require("./exportTransformers"), exports);
43
43
  __exportStar(require("./compassExport"), exports);
44
+ __exportStar(require("./destinations"), exports);
44
45
  __exportStar(require("./benchmarkEvidenceRules"), exports);
45
46
  __exportStar(require("./portfolioAggregate"), exports);
46
47
  __exportStar(require("./tutorialTips"), exports);
package/lib/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;AACA,sDAAoC;AACpC,gEAA8C;AAC9C,0DAAwC;AACxC,2DAAyC;AACzC,kDAAgC;AAChC,uDAAqC;AACrC,4DAA0C;AAC1C,2DAAyC;AAEzC,oDAAkC;AAClC,8CAA4B;AAC5B,kDAAgC;AAChC,kDAAgC;AAChC,qDAAmC;AACnC,mDAAiC;AACjC,qDAAmC;AACnC,mDAAiC;AACjC,+CAA6B;AAC7B,wCAAsB;AACtB,oDAAkC;AAClC,2CAAyB;AACzB,kDAAgC;AAChC,uDAAqC;AACrC,kDAAgC;AAChC,2DAAyC;AACzC,uDAAqC;AACrC,iDAA+B;AAC/B,8CAA4B;AAC5B,qDAAmC;AACnC,0CAAwB;AACxB,yCAAuB;AACvB,4DAA0C;AAC1C,kDAAgC;AAChC,iDAA+B;AAC/B,2CAAyB;AACzB,4DAA0C;AAC1C,oEAAiD;AACjD,yEAAuD;AACvD,kEAAgD;AAChD,kEAAgD;AAChD,oEAAkD;AAClD,yGAAuF;AACvF,0GAAwF;AACxF,+FAA6E;AAC7E,4DAA0C;AAC1C,oEAAkD;AAClD,iEAA+C;AAC/C,gEAA8C;AAC9C,2EAAyD;AACzD,6CAA2B;AAC3B,+CAA6B;AAC7B,8CAA4B;AAC5B,6DAAuE;AAA9D,2IAAA,OAAO,OAAuB"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;AACA,sDAAoC;AACpC,gEAA8C;AAC9C,0DAAwC;AACxC,2DAAyC;AACzC,kDAAgC;AAChC,uDAAqC;AACrC,4DAA0C;AAC1C,2DAAyC;AAEzC,oDAAkC;AAClC,8CAA4B;AAC5B,kDAAgC;AAChC,kDAAgC;AAChC,qDAAmC;AACnC,mDAAiC;AACjC,qDAAmC;AACnC,mDAAiC;AACjC,+CAA6B;AAC7B,wCAAsB;AACtB,oDAAkC;AAClC,2CAAyB;AACzB,kDAAgC;AAChC,uDAAqC;AACrC,kDAAgC;AAChC,iDAA+B;AAC/B,2DAAyC;AACzC,uDAAqC;AACrC,iDAA+B;AAC/B,8CAA4B;AAC5B,qDAAmC;AACnC,0CAAwB;AACxB,yCAAuB;AACvB,4DAA0C;AAC1C,kDAAgC;AAChC,iDAA+B;AAC/B,2CAAyB;AACzB,4DAA0C;AAC1C,oEAAiD;AACjD,yEAAuD;AACvD,kEAAgD;AAChD,kEAAgD;AAChD,oEAAkD;AAClD,yGAAuF;AACvF,0GAAwF;AACxF,+FAA6E;AAC7E,4DAA0C;AAC1C,oEAAkD;AAClD,iEAA+C;AAC/C,gEAA8C;AAC9C,2EAAyD;AACzD,6CAA2B;AAC3B,+CAA6B;AAC7B,8CAA4B;AAC5B,6DAAuE;AAA9D,2IAAA,OAAO,OAAuB"}
@@ -1077,6 +1077,14 @@ export type InstituteData = {
1077
1077
  closeAfterDays?: number;
1078
1078
  };
1079
1079
  destinationsConfig?: DestinationsConfig;
1080
+ /** School-defined leaver destination categories, keyed by id, added from the
1081
+ * Destinations Setup tab. They sit alongside the nine NCCIS codes on every
1082
+ * leaver form and each one names the NCCIS code it counts within, so a school
1083
+ * can break "NEET" down into the situations it actually needs to track
1084
+ * (in hospital, in care, in custody) without breaking statutory reporting. */
1085
+ leaverDestinations?: {
1086
+ [id: string]: LeaverDestination;
1087
+ };
1080
1088
  /** Trust-authored policy, one entry per feature a trust can impose on the schools
1081
1089
  * beneath it. Set on the TRUST's own institute doc and read by its schools through
1082
1090
  * their approved trust link — never copied onto the school, so there is one source
@@ -2227,6 +2235,44 @@ export type ExternalEventRequest = {
2227
2235
  };
2228
2236
  /** NCCIS-compatible destination activity codes (MI Requirement 2026–27) */
2229
2237
  export type DestinationCode = "HE" | "FE" | "APP" | "EWBL" | "EMP" | "VOL" | "OTH" | "NEET" | "NK";
2238
+ /**
2239
+ * A destination as it is actually stored on a response, an alumni record or a
2240
+ * cycle aggregate: one of the nine NCCIS codes above, or the id of a school-defined
2241
+ * category from `InstituteData.leaverDestinations`.
2242
+ *
2243
+ * Custom categories exist because NCCIS's NEET and NK are a single bucket for very
2244
+ * different situations — a SEND leaver in hospital, in care or in custody is "NEET"
2245
+ * to the local authority and nothing more, which is exactly the granularity schools
2246
+ * lose. Every custom category still carries the NCCIS code it counts within
2247
+ * (`LeaverDestination.nccisCode`), so statutory returns are unaffected: only the
2248
+ * school's own reporting gets finer.
2249
+ *
2250
+ * The `(string & {})` half keeps editor autocomplete for the nine standard codes
2251
+ * while still accepting a custom id.
2252
+ */
2253
+ export type DestinationValue = DestinationCode | (string & {});
2254
+ /**
2255
+ * A school-defined leaver destination category, stored on
2256
+ * `InstituteData.leaverDestinations` keyed by `id`.
2257
+ *
2258
+ * `id` is what lands in responses and in `aggregates.destinations.<id>`, so it is
2259
+ * restricted to `[A-Za-z0-9_]` (see DESTINATION_ID_PATTERN) — a Firestore field path
2260
+ * segment can't contain a dot or a slash, and these keys become one.
2261
+ */
2262
+ export type LeaverDestination = {
2263
+ id: string;
2264
+ label: string;
2265
+ /** Which of the nine NCCIS codes this rolls up into for statutory reporting. */
2266
+ nccisCode: DestinationCode;
2267
+ /** Optional helper text shown under the option on the leaver forms. */
2268
+ description?: string;
2269
+ /** Removed from the forms, but kept here so records and charts that already
2270
+ * carry the id keep their label. Deleting outright would leave past responses
2271
+ * showing a raw id. */
2272
+ archived?: boolean;
2273
+ createdAt?: string;
2274
+ createdBy?: string;
2275
+ };
2230
2276
  /** SOC 2020 major group number */
2231
2277
  export type SOCMajorGroup = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9;
2232
2278
  /** Employment detail captured in Form C and stored in CheckInEntry */
@@ -2270,7 +2316,7 @@ export type ApprenticeshipDetails = {
2270
2316
  * Appended on every check-in; source distinguishes self-report from staff/trusted-contact entry.
2271
2317
  */
2272
2318
  export type CheckInEntry = {
2273
- destinationCode: DestinationCode;
2319
+ destinationCode: DestinationValue;
2274
2320
  type?: "annual" | "midYear" | "leaverTransition" | "resultsDay" | "christmas";
2275
2321
  sector?: string;
2276
2322
  date: string;
@@ -2611,7 +2657,7 @@ export type LeaverTransitionData = {
2611
2657
  mobile?: string;
2612
2658
  linkedinUrl?: string;
2613
2659
  activities?: string[];
2614
- intendedDestination: DestinationCode;
2660
+ intendedDestination: DestinationValue;
2615
2661
  intendedDestinationDetails?: EducationDetails | EmploymentDetails | ApprenticeshipDetails;
2616
2662
  intendedStartDate?: string;
2617
2663
  trustedContact?: TrustedContact;
@@ -2652,7 +2698,7 @@ export type LeaverConsentCapture = {
2652
2698
  */
2653
2699
  export type LeaverCheckpointResponse = {
2654
2700
  checkpoint: "resultsDay" | "christmas";
2655
- destination: DestinationCode;
2701
+ destination: DestinationValue;
2656
2702
  destinationDetails?: EducationDetails | EmploymentDetails | ApprenticeshipDetails;
2657
2703
  /** resultsDay only — did results change their plan vs intendedDestination? */
2658
2704
  changedFromIntended?: boolean;
@@ -2713,13 +2759,13 @@ export type Alumni = {
2713
2759
  status: "pending" | "inviteSent" | "leaverReviewed" | "uploaded" | "alumniReviewed" | "declined" | "approved";
2714
2760
  source?: "selfRegistered" | "staffAdded" | "leaverExport";
2715
2761
  /** Intended destination captured via Form B before leaving */
2716
- intendedDestination?: DestinationCode;
2762
+ intendedDestination?: DestinationValue;
2717
2763
  intendedDestinationDetails?: EducationDetails | EmploymentDetails | ApprenticeshipDetails;
2718
2764
  intendedStartDate?: string;
2719
2765
  /** October confirmed actual destination snapshot */
2720
- confirmedDestination?: DestinationCode;
2766
+ confirmedDestination?: DestinationValue;
2721
2767
  /** Most recent known destination — updated on each check-in */
2722
- currentDestination?: DestinationCode;
2768
+ currentDestination?: DestinationValue;
2723
2769
  sector?: string;
2724
2770
  subsector?: string;
2725
2771
  businessSize?: string;
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.1039",
5
+ "version": "1.400.1041",
6
6
  "main": "lib/index.js",
7
7
  "types": "lib/index.d.ts",
8
8
  "scripts": {
@@ -0,0 +1,149 @@
1
+ import {
2
+ customDestinationId,
3
+ resolveLeaverDestinations,
4
+ destinationLabel,
5
+ destinationNccisCode,
6
+ destinationOptions,
7
+ isNegativeDestination,
8
+ LeaverDestinationMap,
9
+ rollUpDestinationCounts,
10
+ validateLeaverDestinations,
11
+ } from "./destinations";
12
+
13
+ const customs: LeaverDestinationMap = {
14
+ custom_in_hospital: {id: "custom_in_hospital", label: "In hospital", nccisCode: "NEET"},
15
+ custom_in_custody: {id: "custom_in_custody", label: "In custody", nccisCode: "NEET"},
16
+ custom_supported_internship: {id: "custom_supported_internship", label: "Supported internship", nccisCode: "EMP"},
17
+ custom_moved_away: {id: "custom_moved_away", label: "Moved away", nccisCode: "NK", archived: true},
18
+ };
19
+
20
+ describe("destinationOptions", () => {
21
+ it("lists the nine NCCIS codes when a school has added nothing", () => {
22
+ const options = destinationOptions();
23
+ expect(options).toHaveLength(9);
24
+ expect(options.every((o) => !o.custom)).toBe(true);
25
+ });
26
+
27
+ it("puts each custom category directly after the code it counts within", () => {
28
+ const codes = destinationOptions(customs).map((o) => o.code);
29
+ expect(codes.indexOf("custom_supported_internship")).toEqual(codes.indexOf("EMP") + 1);
30
+ // Both NEET categories follow NEET, alphabetically.
31
+ expect(codes.slice(codes.indexOf("NEET"), codes.indexOf("NEET") + 3))
32
+ .toEqual(["NEET", "custom_in_custody", "custom_in_hospital"]);
33
+ });
34
+
35
+ it("hides archived categories unless asked for them", () => {
36
+ expect(destinationOptions(customs).map((o) => o.code)).not.toContain("custom_moved_away");
37
+ expect(destinationOptions(customs, {includeArchived: true}).map((o) => o.code))
38
+ .toContain("custom_moved_away");
39
+ });
40
+ });
41
+
42
+ describe("resolveLeaverDestinations", () => {
43
+ const trust = {leaverDestinations: {
44
+ custom_in_care: {id: "custom_in_care", label: "In care", nccisCode: "NEET" as const},
45
+ custom_in_hospital: {id: "custom_in_hospital", label: "In hospital (trust)", nccisCode: "NEET" as const},
46
+ }};
47
+
48
+ it("offers the trust's categories alongside the school's, and names the trust's", () => {
49
+ const {destinations, trustIds} = resolveLeaverDestinations({leaverDestinations: customs}, trust);
50
+ expect(Object.keys(destinations).sort()).toEqual([
51
+ "custom_in_care", "custom_in_custody", "custom_in_hospital",
52
+ "custom_moved_away", "custom_supported_internship",
53
+ ]);
54
+ expect(trustIds).toEqual(["custom_in_care"]);
55
+ });
56
+
57
+ it("leaves the school's own version of a shared id in the school's hands", () => {
58
+ const {destinations, trustIds} = resolveLeaverDestinations({leaverDestinations: customs}, trust);
59
+ expect(destinations.custom_in_hospital.label).toEqual("In hospital");
60
+ expect(trustIds).not.toContain("custom_in_hospital");
61
+ });
62
+
63
+ it("is the school's own map when there is no trust", () => {
64
+ expect(resolveLeaverDestinations({leaverDestinations: customs}).trustIds).toEqual([]);
65
+ expect(resolveLeaverDestinations().destinations).toEqual({});
66
+ });
67
+ });
68
+
69
+ describe("destinationLabel", () => {
70
+ it("reads standard codes and the school's own categories", () => {
71
+ expect(destinationLabel("APP")).toEqual("Apprenticeship");
72
+ expect(destinationLabel("custom_in_hospital", customs)).toEqual("In hospital");
73
+ });
74
+
75
+ it("recovers words from an id defined by another school", () => {
76
+ // A student browsing trust-wide alumni meets categories we don't hold.
77
+ expect(destinationLabel("custom_in_care")).toEqual("In care");
78
+ });
79
+ });
80
+
81
+ describe("destinationNccisCode", () => {
82
+ it("maps a custom category onto the code it reports as", () => {
83
+ expect(destinationNccisCode("custom_in_hospital", customs)).toEqual("NEET");
84
+ expect(destinationNccisCode("custom_supported_internship", customs)).toEqual("EMP");
85
+ });
86
+
87
+ it("treats an unknown id as not known rather than dropping the leaver", () => {
88
+ expect(destinationNccisCode("custom_gone", customs)).toEqual("NK");
89
+ });
90
+
91
+ it("leaves standard codes alone", () => {
92
+ expect(destinationNccisCode("HE", customs)).toEqual("HE");
93
+ });
94
+ });
95
+
96
+ describe("isNegativeDestination", () => {
97
+ it("counts a school's NEET-mapped categories as NEET", () => {
98
+ expect(isNegativeDestination("custom_in_hospital", customs)).toBe(true);
99
+ expect(isNegativeDestination("custom_supported_internship", customs)).toBe(false);
100
+ expect(isNegativeDestination("NK")).toBe(true);
101
+ expect(isNegativeDestination("HE")).toBe(false);
102
+ });
103
+ });
104
+
105
+ describe("rollUpDestinationCounts", () => {
106
+ it("folds custom categories into their NCCIS code", () => {
107
+ const rolled = rollUpDestinationCounts(
108
+ {HE: 10, NEET: 2, custom_in_hospital: 3, custom_in_custody: 1, custom_supported_internship: 4}, customs);
109
+ expect(rolled).toEqual({HE: 10, NEET: 6, EMP: 4});
110
+ });
111
+ });
112
+
113
+ describe("customDestinationId", () => {
114
+ it("derives a field-path-safe id from the label", () => {
115
+ expect(customDestinationId("In hospital")).toEqual("custom_in_hospital");
116
+ });
117
+
118
+ it("suffixes rather than overwriting a category that slugs the same", () => {
119
+ expect(customDestinationId("In-hospital", customs)).toEqual("custom_in_hospital_2");
120
+ });
121
+ });
122
+
123
+ describe("validateLeaverDestinations", () => {
124
+ it("accepts a well-formed set", () => {
125
+ expect(validateLeaverDestinations(customs)).toBeUndefined();
126
+ });
127
+
128
+ it("rejects a category with no name, a bad group, or a duplicate label", () => {
129
+ expect(validateLeaverDestinations({
130
+ custom_x: {id: "custom_x", label: " ", nccisCode: "NEET"},
131
+ })).toBeDefined();
132
+ expect(validateLeaverDestinations({
133
+ custom_x: {id: "custom_x", label: "In hospital", nccisCode: "XX" as never},
134
+ })).toBeDefined();
135
+ expect(validateLeaverDestinations({
136
+ ...customs,
137
+ custom_in_hospital_2: {id: "custom_in_hospital_2", label: "in hospital", nccisCode: "NEET"},
138
+ })).toBeDefined();
139
+ });
140
+
141
+ it("rejects an id that could collide with a standard code or a field path", () => {
142
+ expect(validateLeaverDestinations({
143
+ HE: {id: "HE", label: "Uni", nccisCode: "HE"},
144
+ })).toBeDefined();
145
+ expect(validateLeaverDestinations({
146
+ "custom_a.b": {id: "custom_a.b", label: "Dotted", nccisCode: "NEET"},
147
+ })).toBeDefined();
148
+ });
149
+ });
@@ -0,0 +1,249 @@
1
+ import {DESTINATION_CODES} from "./constants";
2
+ import {DestinationCode, DestinationValue, InstituteData, LeaverDestination} from "./typeDefinitions";
3
+
4
+ /**
5
+ * destinations.ts — the nine NCCIS codes plus whatever a school has added to them.
6
+ *
7
+ * NCCIS gives every leaver one of nine activity codes, and two of them — NEET and
8
+ * NK — cover situations that could hardly be less alike: a leaver in hospital, one
9
+ * in care, one in custody and one who simply hasn't decided are all the same row of
10
+ * the return. Schools with SEND cohorts need that row broken up, so a school can
11
+ * define its own categories (`InstituteData.leaverDestinations`) and every one of
12
+ * them names the NCCIS code it counts within.
13
+ *
14
+ * That mapping is the whole design: a custom category's id is what gets stored and
15
+ * counted, so the school's own charts get the finer breakdown, while anything
16
+ * statutory (Compass+, the LA MI return) resolves it back to one of the nine.
17
+ */
18
+
19
+ export type LeaverDestinationMap = {[id: string]: LeaverDestination};
20
+
21
+ /**
22
+ * One option as the forms and charts see it — standard and custom side by side, so
23
+ * nothing downstream has to know which it is unless it cares.
24
+ */
25
+ export type DestinationOption = {
26
+ /** What gets stored: an NCCIS code, or a custom category's id. */
27
+ code: DestinationValue,
28
+ label: string,
29
+ /** The NCCIS code this counts within — its own code, for the standard nine. */
30
+ nccisCode: DestinationCode,
31
+ custom: boolean,
32
+ description?: string,
33
+ archived?: boolean,
34
+ };
35
+
36
+ /** Ids become Firestore field-path segments (`aggregates.destinations.<id>`), so
37
+ * they may not contain a dot, a slash or anything else a path splits on. */
38
+ export const DESTINATION_ID_PATTERN = /^[A-Za-z0-9_]{1,60}$/;
39
+
40
+ /** Custom ids carry a prefix so nothing can shadow a standard code, now or when
41
+ * NCCIS adds one. */
42
+ export const CUSTOM_DESTINATION_PREFIX = "custom_";
43
+
44
+ /** The NCCIS codes that mean "not in education, employment or training, or we don't
45
+ * know" — the pair schools break down, and the pair a positive-destination rate
46
+ * counts against. */
47
+ export const NEGATIVE_DESTINATION_CODES: DestinationCode[] = ["NEET", "NK"];
48
+
49
+ /**
50
+ * A stable id for a new custom category, derived from its label.
51
+ *
52
+ * @param {string} label the category's display name.
53
+ * @param {object} [existing] categories already defined, so ids stay unique.
54
+ * @return {string} a field-path-safe id.
55
+ */
56
+ export const customDestinationId = (label: string, existing?: LeaverDestinationMap): string => {
57
+ const slug = (label || "")
58
+ .trim().toLowerCase()
59
+ .replace(/[^a-z0-9]+/g, "_")
60
+ .replace(/^_+|_+$/g, "")
61
+ .slice(0, 40) || "category";
62
+ const base = `${CUSTOM_DESTINATION_PREFIX}${slug}`;
63
+ if (!existing?.[base]) return base;
64
+ // Two categories can legitimately slug the same ("In care" / "In-care"), so the
65
+ // second one gets a suffix rather than silently overwriting the first.
66
+ let n = 2;
67
+ while (existing[`${base}_${n}`]) n++;
68
+ return `${base}_${n}`;
69
+ };
70
+
71
+ /** The nine NCCIS codes as options, in the order the MI return lists them. */
72
+ export const standardDestinationOptions = (): DestinationOption[] =>
73
+ Object.entries(DESTINATION_CODES).map(([code, label]) => ({
74
+ code, label, nccisCode: code as DestinationCode, custom: false,
75
+ }));
76
+
77
+ /**
78
+ * Every destination a school offers: the nine standard codes, then its own
79
+ * categories grouped after the NCCIS code each one belongs to.
80
+ *
81
+ * Archived categories are left out unless asked for — they exist so that historical
82
+ * records keep their label, not so that new leavers can pick them.
83
+ *
84
+ * @param {object} [customs] the institute's leaverDestinations map.
85
+ * @param {object} [opts] `includeArchived` to keep withdrawn categories in the list.
86
+ * @return {DestinationOption[]} the ordered option list.
87
+ */
88
+ export const destinationOptions = (
89
+ customs?: LeaverDestinationMap, opts?: {includeArchived?: boolean},
90
+ ): DestinationOption[] => {
91
+ const custom = Object.entries(customs ?? {})
92
+ .filter(([, d]) => d && (opts?.includeArchived || !d.archived))
93
+ .map(([id, d]) => ({
94
+ code: id,
95
+ label: d.label,
96
+ nccisCode: (DESTINATION_CODES[d.nccisCode] ? d.nccisCode : "NEET") as DestinationCode,
97
+ custom: true,
98
+ description: d.description,
99
+ archived: d.archived,
100
+ }))
101
+ .sort((a, b) => a.label.localeCompare(b.label));
102
+
103
+ // Grouped under their parent so "In hospital" reads as a kind of NEET rather than
104
+ // as a tenth code of its own.
105
+ return standardDestinationOptions().flatMap((std) => [
106
+ std, ...custom.filter((c) => c.nccisCode === std.code),
107
+ ]);
108
+ };
109
+
110
+ /**
111
+ * A school's categories, plus the ones its trust supplies.
112
+ *
113
+ * A trust's categories are extra vocabulary, not a mandate: they are added to
114
+ * whatever the school has of its own, and the school can neither edit nor remove
115
+ * them. Nothing is copied onto the school — resolution is a read-time merge, so a
116
+ * school that leaves the trust simply stops seeing them (see ./trustPolicies for why
117
+ * that is how every trust-supplied setting works here).
118
+ *
119
+ * On an id collision the SCHOOL's entry wins. Both keys are the same, so responses
120
+ * and aggregates land in the same place either way; what differs is that the school
121
+ * keeps the category it made, and keeps being able to edit it.
122
+ *
123
+ * @param {object} [school] the school's own institute doc.
124
+ * @param {object} [trust] the trust's institute doc, when the school is in one.
125
+ * @return {object} the merged map, and the ids the trust owns.
126
+ */
127
+ export const resolveLeaverDestinations = (
128
+ school?: Pick<InstituteData, "leaverDestinations">,
129
+ trust?: Pick<InstituteData, "leaverDestinations">,
130
+ ): {destinations: LeaverDestinationMap, trustIds: string[]} => {
131
+ const own = school?.leaverDestinations ?? {};
132
+ const fromTrust = trust?.leaverDestinations ?? {};
133
+ const trustIds = Object.keys(fromTrust).filter((id) => !own[id]);
134
+ return {
135
+ destinations: {...fromTrust, ...own},
136
+ trustIds,
137
+ };
138
+ };
139
+
140
+ /**
141
+ * What to show for a stored destination — a standard label, or a school's own.
142
+ *
143
+ * A category defined by a DIFFERENT school (a student browsing trust-wide alumni
144
+ * meets these) isn't in the map we hold, so its id is turned back into words rather
145
+ * than shown raw: an id is derived from the label it was created with, which makes
146
+ * "custom_in_hospital" recoverable as "In hospital".
147
+ *
148
+ * @param {string} [code] the stored destination.
149
+ * @param {object} [customs] the institute's leaverDestinations map.
150
+ * @return {string|undefined} the display label.
151
+ */
152
+ export const destinationLabel = (
153
+ code?: DestinationValue, customs?: LeaverDestinationMap,
154
+ ): string|undefined => {
155
+ if (!code) return undefined;
156
+ if (DESTINATION_CODES[code]) return DESTINATION_CODES[code];
157
+ const known = customs?.[code]?.label;
158
+ if (known) return known;
159
+ if (!code.startsWith(CUSTOM_DESTINATION_PREFIX)) return code;
160
+ const words = code.slice(CUSTOM_DESTINATION_PREFIX.length).replace(/_/g, " ").trim();
161
+ return words ? words.charAt(0).toUpperCase() + words.slice(1) : code;
162
+ };
163
+
164
+ /**
165
+ * The NCCIS code a stored destination counts within. Statutory exports and the
166
+ * NEET/NK figures go through this, never through the raw value.
167
+ *
168
+ * @param {string} [code] the stored destination.
169
+ * @param {object} [customs] the institute's leaverDestinations map.
170
+ * @return {DestinationCode|undefined} the NCCIS code to report it as.
171
+ */
172
+ export const destinationNccisCode = (
173
+ code?: DestinationValue, customs?: LeaverDestinationMap,
174
+ ): DestinationCode|undefined => {
175
+ if (!code) return undefined;
176
+ if (DESTINATION_CODES[code]) return code as DestinationCode;
177
+ const custom = customs?.[code];
178
+ // An id we no longer recognise is still somebody's leaver: "not known" is the
179
+ // honest reading, and it keeps them in the cohort rather than dropping them.
180
+ if (!custom) return "NK";
181
+ return DESTINATION_CODES[custom.nccisCode] ? custom.nccisCode : "NEET";
182
+ };
183
+
184
+ /**
185
+ * Whether a destination is one of the two the school is trying to shrink. Custom
186
+ * categories count exactly as the NCCIS code they name does.
187
+ *
188
+ * @param {string} [code] the stored destination.
189
+ * @param {object} [customs] the institute's leaverDestinations map.
190
+ * @return {boolean} true for NEET / NK and anything mapped to them.
191
+ */
192
+ export const isNegativeDestination = (
193
+ code?: DestinationValue, customs?: LeaverDestinationMap,
194
+ ): boolean => {
195
+ const nccis = destinationNccisCode(code, customs);
196
+ return Boolean(nccis && NEGATIVE_DESTINATION_CODES.includes(nccis));
197
+ };
198
+
199
+ /**
200
+ * Re-keys a `{destination: count}` aggregate onto the nine NCCIS codes, folding each
201
+ * custom category into its parent. For anything that has to report in NCCIS terms
202
+ * while the raw aggregate stays as granular as the school made it.
203
+ *
204
+ * @param {object} [counts] the raw aggregate.
205
+ * @param {object} [customs] the institute's leaverDestinations map.
206
+ * @return {object} counts keyed by NCCIS code.
207
+ */
208
+ export const rollUpDestinationCounts = (
209
+ counts?: {[code: string]: number}, customs?: LeaverDestinationMap,
210
+ ): {[code: string]: number} => {
211
+ const out: {[code: string]: number} = {};
212
+ for (const [code, n] of Object.entries(counts ?? {})) {
213
+ const key = destinationNccisCode(code, customs) ?? "NK";
214
+ out[key] = (out[key] ?? 0) + (n ?? 0);
215
+ }
216
+ return out;
217
+ };
218
+
219
+ /**
220
+ * Validates a set of custom categories before it is stored. Shared by the setup card
221
+ * and the callable so both refuse the same things.
222
+ *
223
+ * @param {object} destinations the map to check.
224
+ * @return {string|undefined} the first problem found, or undefined if it's valid.
225
+ */
226
+ export const validateLeaverDestinations = (destinations: LeaverDestinationMap): string|undefined => {
227
+ const entries = Object.entries(destinations ?? {});
228
+ if (entries.length > 50) return "You can add up to 50 destination categories.";
229
+ const seen = new Set<string>();
230
+ for (const [id, d] of entries) {
231
+ if (!d) return "One of your categories is empty.";
232
+ if (!DESTINATION_ID_PATTERN.test(id) || id !== d.id) return "A category id is invalid.";
233
+ if (!id.startsWith(CUSTOM_DESTINATION_PREFIX)) return "A category id is invalid.";
234
+ if (DESTINATION_CODES[id]) return "A category can't reuse a standard destination code.";
235
+ const label = (d.label ?? "").trim();
236
+ if (!label) return "Give every category a name.";
237
+ if (label.length > 60) return "Category names must be 60 characters or fewer.";
238
+ if (!DESTINATION_CODES[d.nccisCode]) return `Choose which group "${label}" counts within.`;
239
+ const key = label.toLowerCase();
240
+ if (seen.has(key)) return `You already have a category called "${label}".`;
241
+ // A category that duplicates a standard label would show twice in the picker
242
+ // with no way to tell them apart.
243
+ if (Object.values(DESTINATION_CODES).some((l) => l.toLowerCase() === key)) {
244
+ return `"${label}" is already a standard destination.`;
245
+ }
246
+ seen.add(key);
247
+ }
248
+ return undefined;
249
+ };
package/src/index.ts CHANGED
@@ -23,6 +23,7 @@ export * from "./groups";
23
23
  export * from "./emailSections";
24
24
  export * from "./exportTransformers";
25
25
  export * from "./compassExport";
26
+ export * from "./destinations";
26
27
  export * from "./benchmarkEvidenceRules";
27
28
  export * from "./portfolioAggregate";
28
29
  export * from "./tutorialTips";
@@ -1106,6 +1106,12 @@ export type InstituteData = {
1106
1106
  // Written by the setup wizard (aspirations-saveDestinationsConfig); read by
1107
1107
  // destinationsProvisionCron each September to create that year's surveys/groups.
1108
1108
  destinationsConfig?: DestinationsConfig,
1109
+ /** School-defined leaver destination categories, keyed by id, added from the
1110
+ * Destinations Setup tab. They sit alongside the nine NCCIS codes on every
1111
+ * leaver form and each one names the NCCIS code it counts within, so a school
1112
+ * can break "NEET" down into the situations it actually needs to track
1113
+ * (in hospital, in care, in custody) without breaking statutory reporting. */
1114
+ leaverDestinations?: {[id: string]: LeaverDestination},
1109
1115
  /** Trust-authored policy, one entry per feature a trust can impose on the schools
1110
1116
  * beneath it. Set on the TRUST's own institute doc and read by its schools through
1111
1117
  * their approved trust link — never copied onto the school, so there is one source
@@ -2188,6 +2194,47 @@ export type ExternalEventRequest = {
2188
2194
  /** NCCIS-compatible destination activity codes (MI Requirement 2026–27) */
2189
2195
  export type DestinationCode = "HE"|"FE"|"APP"|"EWBL"|"EMP"|"VOL"|"OTH"|"NEET"|"NK";
2190
2196
 
2197
+ /**
2198
+ * A destination as it is actually stored on a response, an alumni record or a
2199
+ * cycle aggregate: one of the nine NCCIS codes above, or the id of a school-defined
2200
+ * category from `InstituteData.leaverDestinations`.
2201
+ *
2202
+ * Custom categories exist because NCCIS's NEET and NK are a single bucket for very
2203
+ * different situations — a SEND leaver in hospital, in care or in custody is "NEET"
2204
+ * to the local authority and nothing more, which is exactly the granularity schools
2205
+ * lose. Every custom category still carries the NCCIS code it counts within
2206
+ * (`LeaverDestination.nccisCode`), so statutory returns are unaffected: only the
2207
+ * school's own reporting gets finer.
2208
+ *
2209
+ * The `(string & {})` half keeps editor autocomplete for the nine standard codes
2210
+ * while still accepting a custom id.
2211
+ */
2212
+ // eslint-disable-next-line @typescript-eslint/ban-types
2213
+ export type DestinationValue = DestinationCode | (string & {});
2214
+
2215
+ /**
2216
+ * A school-defined leaver destination category, stored on
2217
+ * `InstituteData.leaverDestinations` keyed by `id`.
2218
+ *
2219
+ * `id` is what lands in responses and in `aggregates.destinations.<id>`, so it is
2220
+ * restricted to `[A-Za-z0-9_]` (see DESTINATION_ID_PATTERN) — a Firestore field path
2221
+ * segment can't contain a dot or a slash, and these keys become one.
2222
+ */
2223
+ export type LeaverDestination = {
2224
+ id: string,
2225
+ label: string,
2226
+ /** Which of the nine NCCIS codes this rolls up into for statutory reporting. */
2227
+ nccisCode: DestinationCode,
2228
+ /** Optional helper text shown under the option on the leaver forms. */
2229
+ description?: string,
2230
+ /** Removed from the forms, but kept here so records and charts that already
2231
+ * carry the id keep their label. Deleting outright would leave past responses
2232
+ * showing a raw id. */
2233
+ archived?: boolean,
2234
+ createdAt?: string,
2235
+ createdBy?: string,
2236
+ }
2237
+
2191
2238
  /** SOC 2020 major group number */
2192
2239
  export type SOCMajorGroup = 1|2|3|4|5|6|7|8|9;
2193
2240
 
@@ -2235,7 +2282,7 @@ export type ApprenticeshipDetails = {
2235
2282
  * Appended on every check-in; source distinguishes self-report from staff/trusted-contact entry.
2236
2283
  */
2237
2284
  export type CheckInEntry = {
2238
- destinationCode: DestinationCode,
2285
+ destinationCode: DestinationValue,
2239
2286
  type?: "annual"|"midYear"|"leaverTransition"|"resultsDay"|"christmas", // Timepoint type; defaults to "annual"
2240
2287
  sector?: string,
2241
2288
  date: string, // ISO date string
@@ -2559,7 +2606,7 @@ export type LeaverTransitionData = {
2559
2606
  activities?: string[],
2560
2607
 
2561
2608
  // Destination (NCCIS / Gatsby BM3)
2562
- intendedDestination: DestinationCode,
2609
+ intendedDestination: DestinationValue,
2563
2610
  intendedDestinationDetails?: EducationDetails|EmploymentDetails|ApprenticeshipDetails,
2564
2611
  intendedStartDate?: string,
2565
2612
 
@@ -2606,7 +2653,7 @@ export type LeaverConsentCapture = {
2606
2653
  */
2607
2654
  export type LeaverCheckpointResponse = {
2608
2655
  checkpoint: "resultsDay"|"christmas",
2609
- destination: DestinationCode,
2656
+ destination: DestinationValue,
2610
2657
  destinationDetails?: EducationDetails|EmploymentDetails|ApprenticeshipDetails,
2611
2658
  /** resultsDay only — did results change their plan vs intendedDestination? */
2612
2659
  changedFromIntended?: boolean,
@@ -2680,13 +2727,13 @@ export type Alumni = {
2680
2727
 
2681
2728
  // Destination tracking
2682
2729
  /** Intended destination captured via Form B before leaving */
2683
- intendedDestination?: DestinationCode,
2730
+ intendedDestination?: DestinationValue,
2684
2731
  intendedDestinationDetails?: EducationDetails|EmploymentDetails|ApprenticeshipDetails,
2685
2732
  intendedStartDate?: string,
2686
2733
  /** October confirmed actual destination snapshot */
2687
- confirmedDestination?: DestinationCode,
2734
+ confirmedDestination?: DestinationValue,
2688
2735
  /** Most recent known destination — updated on each check-in */
2689
- currentDestination?: DestinationCode,
2736
+ currentDestination?: DestinationValue,
2690
2737
 
2691
2738
  // Legacy fields — retained for backwards compatibility; use CheckInEntry going forward
2692
2739
  sector?: string,