Files
threadcount-community/lib/sets.ts
T
ThreadCount 822c0b7c0b ThreadCount Community edition
Uniform stock management for healthcare linen rooms: the coordinator app, the phone counter and the staff app, for your own server. Built from 8685140 on 2026-09-13. Licensed under the Functional Source License (FSL-1.1-ALv2).
2026-09-13 11:09:20 +10:00

294 lines
18 KiB
TypeScript

/* Set allowances, and what a set is.
*
* One ceiling, and it is a ceiling on what somebody is HOLDING: six sets, at any time, for every
* group in the building. Not six a year — nothing here resets in July, and there is no date in this
* file at all. Six sets is what a person has on their back and in their locker, so the only way past
* a full six is to hand something in — a swap, not an addition — or a coordinator's override,
* recorded as the exception it is.
*
* Each facility names its own staff groups and puts each one on one of three routes. The routes
* differ in how somebody gets up to that ceiling, never in whether they have one:
*
* - The FTE table. The hours worked propose the number — a full-timer five sets, a half-timer
* three, a casual whatever the manager thinks right — and a manager may sign above the proposal,
* up to the ceiling. The proposal lives in lib/compute with the rest of the FTE table; what lives
* here is the ceiling it is signed up to.
* - The starting kit. A fixed number of sets on the first day, then more as they need them, up to
* the same ceiling. Nothing has to be handed back first: asked whether the first set had to come
* back before the next was issued, the owner said no.
* - Manager approval. No starting kit; their manager approves a set at a time.
*
* Which group is on which route is the facility's own answer, two lists of names on its settings
* (Facility.nursingGroups for the FTE table, Facility.kitGroups for the starting kit). Nothing here
* guesses it from the letters in a group's name: the same job is "Housekeeping" in one building and
* "Support Services" in the next, and a guess that misses one takes a whole team's first kit away.
* lib/compute reads the lists; this file is told the answer.
*
* A **set is one top and one pair of trousers**. That matters because ThreadCount's entitlement used
* to count individual garments, and counting garments would let someone take three tops and no
* trousers and be "fully issued". Sets held is therefore the smaller of the two counts, which is
* also how anybody would describe it out loud.
*
* The smaller of the two counts is not a ceiling on its own, though: twenty tops and one pair of
* trousers is "one set" by that measure. So the ceiling bites on each half — at most six tops AND at
* most six pairs — which is what capState() below works out, and why it answers in halves rather
* than with a single number.
*
* This file is the one place that answers "how many sets may this person hold". The starting figure
* and the ceiling are facility settings (Facility.initialSets, Facility.capSets) and are passed in —
* but the sums they go into, and the figures a facility that has never been asked falls back to,
* live here and nowhere else. The counter screens ask through lib/compute, which defers to these;
* that is why nothing here may import compute, or the two would be a cycle.
*/
/** The two fields any of these predicates read of a garment. Callers holding a narrow select (the
* staff app reads the catalogue without costs) don't have to fake a whole Item to ask. */
export type Garment = { type?: string; item?: string };
/** The garment types that make up the two halves of a set. lib/compute re-exports them and builds
* the catalogue's full type vocabulary around them. */
export const TOP_TYPES = ["Shirt", "Polo", "Tunic", "Scrub top", "Blouse"];
export const PANT_TYPES = ["Pants", "Trousers", "Cargo pants", "Shorts", "Skort", "Skirt"];
/** Case-insensitive match against one of those vocabularies. Exported only so lib/compute's own
* blocks — maternity, outerwear — can ask the same question the same way. */
export const isTypeIn = (types: readonly string[], type: string) =>
types.some((t) => t.toLowerCase() === type.toLowerCase());
// An explicit type wins; items saved before the field existed keep the old name-based guess.
//
// The trap worth knowing: type is a free-text field with a datalist behind it, not a closed list,
// and both of these read it as an exact (case-insensitive) match against the vocabulary above. A
// hand-typed "scrubs" or "Scrub Tops" therefore answers false everywhere — worse than leaving type
// blank, which at least falls back to the garment name. Ask through these helpers rather than
// comparing `it.type` yourself, or one typo in the catalogue quietly stops a garment counting as a
// top, and a wearer's holdings stop pairing into sets.
export const isTopItem = (it: Garment | undefined) =>
it?.type ? isTypeIn(TOP_TYPES, it.type) : /top|shirt|polo|tunic|blouse/i.test(it?.item || "");
export const isPantItem = (it: Garment | undefined) =>
it?.type ? isTypeIn(PANT_TYPES, it.type) : /pant|bottom|trouser|skort|short|cargo/i.test(it?.item || "");
/** A set is a top and a bottom. Two garments, for every stream, everywhere the word "set" is used —
* named rather than written as a bare 2, because a bare 2 in a sum is indistinguishable from a
* rounding fudge six months later. */
export const SET_GARMENTS = 2;
/** The starting allocation for a facility that has never been asked. A facility sets its own figure
* (Settings.initialSets, seeded with this one); this is the fallback, not a second rule. */
export const SETS_ON_START = 3;
/** The ceiling, for a facility that has never been asked (Facility.capSets). Six sets is twelve
* garments, and it is everybody's: every group ends at the same place, whichever of the three
* routes it took to get there. */
export const SETS_CAP = 6;
/** The starting sets this facility issues *to the groups on the starting-kit route*. Takes the
* configured figure and stands in for it when there isn't one: a blank or a nonsense number must not
* become an offer of zero sets to somebody starting on Monday, so the standing figure holds until a
* coordinator says otherwise.
*
* Whose number it is matters as much as what it is. Nobody on the other two routes starts on it —
* the FTE table proposes its own number, and manager approval starts on nothing at all — so asking
* this about somebody on manager approval answers a question that was never put, and quoting the
* answer to them promises a kit the counter would turn them away for.
*
* Fractions are floored — the counter can only hand over whole tops and whole trousers, so half a
* set is a loose garment, not an entitlement. */
export function setsOnStart(configured?: number | null): number {
const n = Number(configured);
return Number.isFinite(n) && n > 0 ? Math.floor(n) : SETS_ON_START;
}
/** This facility's ceiling, in sets held at any one time, for anybody at all. Same treatment as the
* starting figure: a blank or a nonsense number must not turn into a ceiling of zero that declines
* the whole hospital, so the standing figure holds until a coordinator says otherwise, and
* fractions are floored because half a set is a loose garment. */
export function setsCap(configured?: number | null): number {
const n = Number(configured);
return Number.isFinite(n) && n > 0 ? Math.floor(n) : SETS_CAP;
}
/** The three routes to the ceiling: the FTE table, the starting kit, and manager approval. */
export type AllowanceRoute = "fte" | "kit" | "approval";
/** Which route somebody is on, given the facility's two answers about their group — is it on the FTE
* table list, is it on the starting-kit list. On neither is manager approval.
*
* The server refuses a group on both lists, but a backup restored from a file somebody edited could
* still carry one, and every screen has to give the same answer when it does. The FTE table wins.
* The order form always asked it first, so a group caught on both goes on getting what it got
* before; and it is the route where a manager's signature stands behind anything past the table's
* proposal, which is the safer of the two to land on than a fixed kit handed over the counter with
* nobody signing. Decided here, once, so the order form, the counter, the wearer's app and the
* manager's review cannot each settle it differently. */
export function allowanceRoute(on: { nursing?: boolean; kit?: boolean }): AllowanceRoute {
if (on.nursing) return "fte";
if (on.kit) return "kit";
return "approval";
}
/** Which half of a set a garment is, or null when it is no part of one — outerwear, maternity wear,
* a hat. One question asked in one place, so that the counter, the wearer's app and the manager's
* review screen never disagree about whether a fleece paired with a pair of trousers.
*
* A maternity garment entered with its proper type answers null here, because a maternity tunic is
* never swapped for a standard one and the two of them are not the two-piece uniform. One saved
* with no type at all is read by its name, exactly as every other untyped garment is. */
export function setHalf(it: Garment | undefined): "top" | "pants" | null {
if (isTopItem(it)) return "top";
if (isPantItem(it)) return "pants";
return null;
}
/** A pile of garments counted the way the ceiling reads it: tops, trousers, whatever is in no set at
* all, and the complete sets the first two make between them. */
export type GarmentCounts = { tops: number; pants: number; other: number; sets: number };
export function garmentCounts(holdings: { item: Garment; qty: number }[]): GarmentCounts {
let tops = 0, pants = 0, other = 0;
for (const h of holdings) {
const half = setHalf(h.item);
if (half === "top") tops += h.qty;
else if (half === "pants") pants += h.qty;
else other += h.qty;
}
return { tops, pants, other, sets: Math.min(tops, pants) };
}
/** How many complete sets a person is holding, from their current holdings. */
export function setsHeld(holdings: { item: Garment; qty: number }[]): number {
return garmentCounts(holdings).sets;
}
/** Loose garments that don't yet pair into a set — useful for saying "3 sets and a spare top". */
export function looseGarments(holdings: { item: Garment; qty: number }[]): { tops: number; pants: number } {
const c = garmentCounts(holdings);
return { tops: c.tops - c.sets, pants: c.pants - c.sets };
}
const plural = (n: number, one: string, many: string) => `${n} ${n === 1 ? one : many}`;
/** The ceiling, worked out for one person and whatever is about to be handed over, in the parts a
* screen or a coordinator needs to see the reason.
*
* `cap` is sets. The two halves are counted separately against it — at most six tops and at most
* six pairs — because the smaller of the two counts is not a ceiling: twenty tops and one pair is
* "one set" by that measure, and a locker with twenty tops in it is exactly what the ceiling is
* there to prevent.
*
* `other` is everything that is no part of a set — a fleece, a jacket, maternity wear. The six-set
* ceiling says nothing about those, so they carry the same number as a ceiling of their own,
* counted in garments: nobody needs seven jackets at once either, and with no ceiling at all they
* would be the one thing in the building nothing ever asked about. The same figure as the sets
* ceiling deliberately, so a coordinator has one number to remember and nobody has to keep two
* settings in step.
*
* `over` is the counter's question — after this hand-over, is this person still inside what one
* person holds. `breach` says which of the three ceilings it is and `overBy` how many garments past
* it, because a coordinator asked to tick an override is owed something they can check against the
* person in front of them: "holds six tops and six pairs already" is checkable, a bare refusal is
* not. Past it, the way on is a hand-in — a swap rather than an addition — or that override. */
export type CapState = {
cap: number;
otherCap: number;
tops: number; pants: number; other: number; sets: number;
addTops: number; addPants: number; addOther: number;
afterTops: number; afterPants: number; afterOther: number; afterSets: number;
overTops: number; overPants: number; overOther: number;
over: boolean;
overBy: number;
breach: "tops" | "pants" | "both" | "other" | null;
note: string;
};
export function capState(opts: {
held: { tops: number; pants: number; other?: number };
adding?: { tops?: number; pants?: number; other?: number };
capSets?: number | null;
}): CapState {
const cap = setsCap(opts.capSets);
const tops = Math.max(0, opts.held.tops), pants = Math.max(0, opts.held.pants), other = Math.max(0, opts.held.other || 0);
const addTops = Math.max(0, opts.adding?.tops || 0), addPants = Math.max(0, opts.adding?.pants || 0), addOther = Math.max(0, opts.adding?.other || 0);
const afterTops = tops + addTops, afterPants = pants + addPants, afterOther = other + addOther;
const overTops = Math.max(0, afterTops - cap), overPants = Math.max(0, afterPants - cap), overOther = Math.max(0, afterOther - cap);
const breach = overTops && overPants ? "both" : overTops ? "tops" : overPants ? "pants" : overOther ? "other" : null;
const over = breach !== null;
const overBy = Math.max(overTops, overPants, overOther);
const sets = Math.min(tops, pants), afterSets = Math.min(afterTops, afterPants);
// Said in garments as well as in sets, because a hand-over is garments: "6 sets" on its own tells
// somebody holding six tops and two pairs nothing about why they are being turned away.
const holds = `${plural(afterTops, "top", "tops")} and ${plural(afterPants, "pair", "pairs")}`;
const note = !over
? `${afterSets} of ${cap} sets — ${holds}.${afterOther ? ` Plus ${plural(afterOther, "garment", "garments")} outside a set.` : ""}`
: breach === "other"
? `That would be ${plural(afterOther, "garment", "garments")} outside a set, and ${cap} outside a set is the most anyone holds. Hand one in to make room, or a coordinator can record an override.`
: `That would be ${holds}, and the most anyone holds is ${cap} sets — ${cap} tops and ${cap} pairs. Hand ${breach === "both" ? "a top and a pair" : breach === "tops" ? "a top" : "a pair"} in to make room, or a coordinator can record an override.`;
return {
cap, otherCap: cap,
tops, pants, other, sets,
addTops, addPants, addOther,
afterTops, afterPants, afterOther, afterSets,
overTops, overPants, overOther,
over, overBy, breach, note,
};
}
/** What this person may hold, and how they get there.
*
* `cap` and `max` are both the facility's ceiling, for every route. The starting-kit route used to be
* the exception — its second allocation waited on a hand-in — until the owner said nothing has to
* come back before the next set is issued. So there is one figure, and it is the one the counter
* refuses on; `cap` is kept beside `max` only so nothing calling this had to change the same day.
*
* `start` is the starting-kit route's figure — what they are handed on the first day — and null on
* the other two routes, whose starting number comes from the FTE table or a signature.
*
* The note is written about nobody in particular, because it lands in three places — the wearer's own
* app, the manager's review, and the counter — and "your manager" means a different person in each.
*
* `nursing` and `kit` are the facility's own answers about this person's group — is it on the FTE
* table list, is it on the starting-kit list — from lib/compute's isNursingGroup() and isKitGroup(),
* which read the facility's lists of names. They have to be passed in because this file has no
* facility to ask, and allowanceRoute() above turns the pair into one route. Neither decides
* whether there is a ceiling, only which sentence describes the way to it and whether there is a
* starting figure. Deciding either here from the group name would mean a second test for who is on
* which route, and two tests drift: the last one cost a whole group their allowance when somebody
* renamed a label. Leave `kit` off and the person is read as being on manager approval — told they
* start on nothing — so every caller that can reach the facility's lists has to pass both.
*
* `group` is no longer read: the route comes from the two answers above. It stays in the signature
* so that nothing calling this had to change the day the letters in a name stopped deciding it.
*
* `capped` is always true now and `cap` is never null. Both are kept so that nothing calling this
* had to change on the same day the rule did, and both can go once the screens have.
*
* `startingSets` and `capSets` are the facility's configured figures. Both are optional because the
* staff app holds no facility register, and a wearer's screen quoting the standing figure beats it
* quoting nothing — but any caller that can reach settings should pass them, or a site that issues
* four will go on telling its wearers three. */
export function allowance(opts: {
group?: string | null;
held: number;
startingSets?: number | null;
nursing?: boolean;
kit?: boolean;
capSets?: number | null;
}): { capped: boolean; cap: number | null; max: number; start: number | null; used: number; note: string } {
const max = setsCap(opts.capSets);
const garments = max * SET_GARMENTS;
const route = allowanceRoute(opts);
if (route !== "kit") {
// The FTE table and manager approval both end at the ceiling; what differs is what proposes the
// number on the way up. On the table somebody's hours propose theirs and a manager may sign
// above it; on approval nothing is proposed at all and each set comes with a signature.
const note = route === "fte"
? `Up to ${max} sets — ${garments} garments — at any time. The hours worked propose the starting number, and a manager can sign for more, up to that.`
: `Up to ${max} sets — ${garments} garments — at any time, each one approved by a manager.`;
return { capped: true, cap: max, max, start: null, used: opts.held, note };
}
// Never above the ceiling: a site that set its starting figure to seven would otherwise print an
// offer the counter then declines.
const start = Math.min(max, setsOnStart(opts.startingSets));
return {
capped: true, cap: max, max, start, used: opts.held,
note: `${start} sets on starting, then more as needed, up to ${max} sets — ${garments} garments — at any time. Nothing has to be handed back first.`,
};
}