@7365admin1/layer-common 4.146.0 → 4.147.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -6,7 +6,7 @@
6
6
  <v-card class="screen-modal qr-scanner">
7
7
  <div class="qr-scanner__head">
8
8
  <v-card-title>Scan QR Code</v-card-title>
9
- <AppButton variant="icon" icon="mdi-close" @click="closeDialog()" />
9
+ <v-btn icon="mdi-close" variant="text" aria-label="Close" @click="closeDialog()" />
10
10
  </div>
11
11
 
12
12
  <div class="screen-modal__body qr-scanner__body">
@@ -41,7 +41,7 @@
41
41
  fires, what it links to, or how long it stays; only how it is drawn.
42
42
  -->
43
43
  <v-snackbar
44
- v-if="!isUnregisteredVisitorRoute && enableUnregisteredSnackbar && canViewVisitor"
44
+ v-if="enableUnregisteredSnackbar && canViewVisitor"
45
45
  v-model="snackBarMessage.modal"
46
46
  :timeout="-1"
47
47
  location="bottom center"
@@ -75,8 +75,10 @@
75
75
  </div>
76
76
 
77
77
  <div class="anpr-toast__actions">
78
+ <!-- "Register" for a user who can register (Update right), as the
79
+ security mobile app; otherwise only through to the list. -->
78
80
  <AppButton variant="ghost" @click="goToUnregistered">
79
- Go to Unregistered
81
+ {{ canUpdateVisitor ? "Register" : "Go to Unregistered" }}
80
82
  </AppButton>
81
83
  </div>
82
84
  </v-snackbar>
@@ -131,6 +133,11 @@ const props = defineProps({
131
133
  type: Boolean,
132
134
  default: false,
133
135
  },
136
+ /** The visitor Update right: the notice's button reads "Register". */
137
+ canUpdateVisitor: {
138
+ type: Boolean,
139
+ default: false,
140
+ },
134
141
  });
135
142
 
136
143
  const config = useRuntimeConfig();
@@ -141,8 +148,36 @@ const { tab } = useVisitor();
141
148
  const { enableUnregistered, ensureANPRSettings } = useANPRSettings(
142
149
  () => props.siteId
143
150
  );
144
- const enableUnregisteredSnackbar = computed(() => enableUnregistered.value);
145
- const { socketUnregisteredVisitorTrigger, visitorSocketData } =
151
+ // The security mobile app's rule (isecure365 `useUnregisteredTab`): the
152
+ // site's Unregistered switch AND an ANPR camera facing an entrance (entry /
153
+ // both). With the switch alone the notice could appear for a site whose
154
+ // cameras never register anyone arriving.
155
+ const { getAllSiteCameras } = useSiteSettings();
156
+ const hasEntryFacingAnprCamera = ref(false);
157
+
158
+ async function loadEntryFacingAnprCamera(siteId: string) {
159
+ hasEntryFacingAnprCamera.value = false;
160
+ if (!siteId) return;
161
+ try {
162
+ const res = await getAllSiteCameras({ site: siteId, type: "anpr", page: 1, limit: 100 });
163
+ if (siteId !== props.siteId) return;
164
+ const items = Array.isArray(res?.items)
165
+ ? res.items
166
+ : Array.isArray(res?.data?.items)
167
+ ? res.data.items
168
+ : [];
169
+ hasEntryFacingAnprCamera.value = items.some(
170
+ (camera: any) => camera?.direction === "entry" || camera?.direction === "both"
171
+ );
172
+ } catch {
173
+ hasEntryFacingAnprCamera.value = false;
174
+ }
175
+ }
176
+
177
+ const enableUnregisteredSnackbar = computed(
178
+ () => enableUnregistered.value && hasEntryFacingAnprCamera.value
179
+ );
180
+ const { socketUnregisteredVisitorTrigger, visitorSocketData, registerVisitorFormData } =
146
181
  useVisitorSocket();
147
182
 
148
183
  const APP_NAME = publicConfig.APP || "";
@@ -260,10 +295,10 @@ const showPlate = (plateNumber: string) => {
260
295
  }
261
296
 
262
297
  snackBarMessage.plateNumber = plateNumber;
298
+ // No timeout (security mobile app, 2026-10): it stays on every screen until
299
+ // it is closed or its button is used. A newer plate replaces it. (The
300
+ // window below is no longer started; its cancels are harmless.)
263
301
  snackBarMessage.modal = true;
264
- // Restart the 20s window from zero. A replacement plate gets a full window
265
- // of its own; without this it inherited whatever was left of the one before.
266
- plateToastWindow.restart();
267
302
  };
268
303
 
269
304
  const connectSocket = () => {
@@ -284,17 +319,27 @@ const connectSocket = () => {
284
319
 
285
320
  socket.value.on("anpr-messages", (data: TVisitorSocketData) => {
286
321
  const plateNumber = data?.plateNumber?.trim() || "";
287
- const isCheckIn = Boolean(plateNumber);
288
- const isPlateNumberChanging =
289
- Boolean(lastPlateNumber.value) && lastPlateNumber.value !== plateNumber;
322
+ const recordId = String(data?._id ?? "").trim();
290
323
 
291
- socketUnregisteredVisitorTrigger.value = false;
324
+ // Every message still reaches the visitor list (its reload).
292
325
  visitorSocketData.value = null;
293
326
  visitorSocketData.value = data;
294
327
  newVisitor.value = data;
295
328
 
296
- if (isCheckIn) {
297
- showPlate(plateNumber);
329
+ if (plateNumber) {
330
+ // An arrival. Only one with both the record id and the plate is kept for
331
+ // Register, and it drops any outstanding trigger; a read with no record
332
+ // has nothing to register and raises no notice (security mobile app).
333
+ // Nothing else on the feed touches the stored arrival.
334
+ if (recordId) {
335
+ registerVisitorFormData.value = {
336
+ _id: recordId,
337
+ plateNumber,
338
+ site: data?.site,
339
+ };
340
+ socketUnregisteredVisitorTrigger.value = false;
341
+ showPlate(plateNumber);
342
+ }
298
343
  lastPlateNumber.value = plateNumber;
299
344
  return;
300
345
  }
@@ -354,24 +399,27 @@ const goToUnregistered = async () => {
354
399
  });
355
400
  };
356
401
 
402
+ // A site switch, or leaving the signed-in layout (logout): the open notice and
403
+ // the stored arrival belong to the old site, so neither survives it.
404
+ const clearPlateNotice = () => {
405
+ snackBarMessage.modal = false;
406
+ registerVisitorFormData.value = null;
407
+ socketUnregisteredVisitorTrigger.value = false;
408
+ };
409
+
357
410
  watch(
358
411
  () => props.siteId,
359
- () => {
412
+ (siteId, previous) => {
413
+ if (previous !== undefined && siteId !== previous) clearPlateNotice();
360
414
  disconnectSocket();
361
415
  connectSocket();
416
+ void loadEntryFacingAnprCamera(siteId);
362
417
  },
363
418
  { immediate: true, deep: true }
364
419
  );
365
420
 
366
- watch(
367
- () => route.fullPath,
368
- () => {
369
- if (isUnregisteredVisitorRoute.value) {
370
- snackBarMessage.modal = false;
371
- }
372
- },
373
- { immediate: true }
374
- );
421
+ // The notice now also shows on the Unregistered tab (security mobile app):
422
+ // its button opens Register for the record there.
375
423
 
376
424
  watch(enableUnregisteredSnackbar, (enabled) => {
377
425
  if (!enabled) {
@@ -402,6 +450,7 @@ watch(
402
450
  );
403
451
 
404
452
  onUnmounted(() => {
453
+ clearPlateNotice();
405
454
  plateToastWindow.cancel();
406
455
  if (typeof document !== "undefined") {
407
456
  document.removeEventListener("keydown", onEscape);
@@ -1,3 +1,10 @@
1
+ import {
2
+ VISITOR_PERSON_TYPES,
3
+ recordIdsOf,
4
+ visitorPeopleResults,
5
+ type VisitorPerson,
6
+ } from "../utils/visitor-people";
7
+
1
8
  export default function () {
2
9
  async function getAll({
3
10
  page = 1,
@@ -66,6 +73,51 @@ export default function () {
66
73
  });
67
74
  }
68
75
 
76
+ /**
77
+ * The visitor form's autofill lookup across EVERY visitor type, as the
78
+ * security mobile app (isecure365 `findAnyVisitorType`): one request with
79
+ * `type[]=guest&type[]=contractor&type[]=delivery`, the type being registered
80
+ * listed first, and one person on file under several types merged into one
81
+ * result. `walk-in` is looked up as `guest`.
82
+ */
83
+ async function findVisitorPeopleAnyType(
84
+ by: "nric" | "contact" | "plateNumber",
85
+ term: string,
86
+ site?: string,
87
+ preferType?: string
88
+ ): Promise<VisitorPerson[]> {
89
+ const res = await $fetch<any>(`/api/people`, {
90
+ method: "GET",
91
+ query: {
92
+ [by]: term,
93
+ ...(site ? { site } : {}),
94
+ "type[]": [...VISITOR_PERSON_TYPES],
95
+ },
96
+ });
97
+ const items: VisitorPerson[] = Array.isArray(res)
98
+ ? res
99
+ : Array.isArray(res?.items)
100
+ ? res.items
101
+ : Array.isArray(res?.data?.items)
102
+ ? res.data.items
103
+ : Array.isArray(res?.data)
104
+ ? res.data
105
+ : [];
106
+ const type = preferType === "walk-in" ? "guest" : preferType;
107
+ return visitorPeopleResults(items, type);
108
+ }
109
+
110
+ /**
111
+ * Deletes the People records behind one autofill result (a merged result is
112
+ * several): `PUT /api/people/:id`, a soft delete. For a person saved with a
113
+ * wrong NRIC; past visits are not touched.
114
+ */
115
+ async function deletePersonRecords(person: VisitorPerson) {
116
+ for (const id of recordIdsOf(person)) {
117
+ await deleteById(id);
118
+ }
119
+ }
120
+
69
121
  async function findPersonByContact(
70
122
  contact: string
71
123
  ): Promise<null | Partial<TPeople>> {
@@ -164,6 +216,8 @@ export default function () {
164
216
  findPersonByNRICMultipleResult,
165
217
  findPersonByPhoneNumberMultipleResult,
166
218
  findPersonByPlateNumberMultipleResult,
219
+ findVisitorPeopleAnyType,
220
+ deletePersonRecords,
167
221
  findPersonByContact,
168
222
  getPeopleByUnit,
169
223
  getOccupiedStructure,
@@ -4,11 +4,13 @@ export default function () {
4
4
  value: TVisitorType;
5
5
  };
6
6
 
7
+ // The Add type picker, in iSecure's order and words (`VISITOR_FORM_TYPES` /
8
+ // `VISITOR_FORM_TITLES`). The values are the stored types - never rename.
7
9
  const visitorSelection: TVisitorSelection[] = [
8
- { label: "Drive-in", value: "guest" },
10
+ { label: "Guest", value: "guest" },
9
11
  { label: "Contractor", value: "contractor" },
10
- { label: "Walk-In", value: "walk-in" },
11
12
  { label: "Delivery", value: "delivery" },
13
+ { label: "Walk-In", value: "walk-in" },
12
14
  { label: "Pick-Up", value: "pick-up" },
13
15
  { label: "Drop-Off", value: "drop-off" },
14
16
  ];
@@ -64,7 +64,25 @@ export const plateToast = (
64
64
  };
65
65
  };
66
66
 
67
+ /**
68
+ * The last vehicle ARRIVAL the ANPR camera read, kept so the plate notice's
69
+ * Register / "Go to Unregistered" opens the Register flow for THAT record.
70
+ * As the security mobile app (isecure365 `anpr-unregistered.ts`): only a read
71
+ * with both the record id and the plate is kept, and nothing else on the feed
72
+ * (a camera alert, a reload) replaces it.
73
+ */
74
+ export type TRegisterVisitorFormData = {
75
+ /** The unregistered record the camera created - the one the form updates. */
76
+ _id: string;
77
+ plateNumber: string;
78
+ site?: string;
79
+ };
80
+
67
81
  export const useVisitorSocket = () => {
82
+ const registerVisitorFormData = useState(
83
+ "registerVisitorFormData",
84
+ (): TRegisterVisitorFormData | null => null,
85
+ );
68
86
  const socketUnregisteredVisitorTrigger = useState(
69
87
  "socketUnregisteredVisitorTrigger",
70
88
  () => false,
@@ -76,6 +94,7 @@ export const useVisitorSocket = () => {
76
94
 
77
95
 
78
96
  return {
97
+ registerVisitorFormData,
79
98
  socketUnregisteredVisitorTrigger,
80
99
  visitorSocketData,
81
100
  };
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@7365admin1/layer-common",
3
3
  "license": "MIT",
4
4
  "type": "module",
5
- "version": "4.146.0",
5
+ "version": "4.147.1",
6
6
  "author": "7365admin1",
7
7
  "main": "./nuxt.config.ts",
8
8
  "//files": "What a consumer extending this layer actually loads. Without this npm ships the whole working tree - the changesets, the CI workflows, the render harness in tools/ and any scratch directory that happened to exist at publish time. Nuxt resolves a layer by directory, so every runtime directory below has to stay listed; adding a new top-level runtime directory means adding it here too.",
@@ -69,6 +69,7 @@
69
69
  "sass": "^1.80.6",
70
70
  "sip.js": "0.21.2",
71
71
  "socket.io-client": "^4.8.3",
72
+ "tesseract.js": "^5.1.1",
72
73
  "vue-draggable-next": "^2.3.0",
73
74
  "vue3-signature": "^0.2.4",
74
75
  "vuetify-pro-tiptap": "^2.8.2",
@@ -0,0 +1,278 @@
1
+ /**
2
+ * Ported verbatim from the security mobile app (isecure365
3
+ * `src/features/visitors/nric-card.ts`) so the web NRIC scan reads a card
4
+ * exactly as the app does. Keep the two in step.
5
+ *
6
+ * Reading a Singapore NRIC (or FIN card) from the text the camera saw.
7
+ *
8
+ * The visitor form's "Scan" beside the NRIC field photographs the card, runs
9
+ * on-device text recognition (`modules/text-recognizer`) and hands the lines
10
+ * here. Nothing leaves the device: the photo is deleted straight after it is
11
+ * read, and only the two values below reach the form.
12
+ *
13
+ * The front of the card reads, top to bottom:
14
+ *
15
+ * REPUBLIC OF SINGAPORE
16
+ * IDENTITY CARD NO. S1234567D
17
+ * Name
18
+ * TAN AH KOW <- may wrap onto a second line
19
+ * (name in Chinese / Tamil / Jawi, when there is one)
20
+ * Race ... Date of birth ... Sex ... Country/Place of birth ...
21
+ *
22
+ * The back carries the number again (with its barcode), so the number alone
23
+ * can also be read from the back.
24
+ */
25
+
26
+ /** One line of recognised text and where it sits in the photo. */
27
+ export type CardLine = {
28
+ text: string;
29
+ x: number;
30
+ y: number;
31
+ width: number;
32
+ height: number;
33
+ };
34
+
35
+ export type NricCardRead = {
36
+ nric: string | null;
37
+ name: string | null;
38
+ };
39
+
40
+ const WEIGHTS = [2, 7, 6, 5, 4, 3, 2];
41
+
42
+ /** Check letter by remainder, per prefix family. `T` / `G` add 4 first. */
43
+ const CHECK_LETTERS: Record<string, { letters: string; offset: number }> = {
44
+ S: { letters: "JZIHGFEDCBA", offset: 0 },
45
+ T: { letters: "JZIHGFEDCBA", offset: 4 },
46
+ F: { letters: "XWUTRQPNMLK", offset: 0 },
47
+ G: { letters: "XWUTRQPNMLK", offset: 4 },
48
+ };
49
+
50
+ /**
51
+ * Whether the last letter matches the number. `null` for an M-series FIN,
52
+ * whose check letter is not checked here: the scanner instead waits for the
53
+ * same M number to be read twice in a row.
54
+ */
55
+ export function isNricChecksumValid(nric: string): boolean | null {
56
+ const value = nric.toUpperCase();
57
+ if (!/^[STFGM]\d{7}[A-Z]$/.test(value)) return false;
58
+ const rule = CHECK_LETTERS[value[0]];
59
+ if (!rule) return null;
60
+ let sum = rule.offset;
61
+ for (let i = 0; i < 7; i += 1) sum += Number(value[i + 1]) * WEIGHTS[i];
62
+ return rule.letters[sum % 11] === value[8];
63
+ }
64
+
65
+ /** Characters text recognition commonly puts where a digit is printed. */
66
+ const AS_DIGIT: Record<string, string> = {
67
+ O: "0",
68
+ D: "0",
69
+ Q: "0",
70
+ I: "1",
71
+ L: "1",
72
+ Z: "2",
73
+ S: "5",
74
+ G: "6",
75
+ B: "8",
76
+ };
77
+
78
+ /** A letter, seven digit-like characters, a letter — before any fixing. */
79
+ const NRIC_LIKE = /^[STFGM5][0-9ODQILZSGB]{7}[A-Z]$/;
80
+
81
+ function fixCandidate(raw: string): string {
82
+ const prefix = raw[0] === "5" ? "S" : raw[0];
83
+ const digits = raw
84
+ .slice(1, 8)
85
+ .split("")
86
+ .map((ch) => AS_DIGIT[ch] ?? ch)
87
+ .join("");
88
+ return `${prefix}${digits}${raw[8]}`;
89
+ }
90
+
91
+ /**
92
+ * The NRIC/FIN in the text, or null. A number whose check letter matches wins
93
+ * over one that only has the right shape (an M-series FIN).
94
+ */
95
+ export function findNric(lines: CardLine[]): string | null {
96
+ const texts = lines.map((line) => line.text);
97
+ // Each line on its own, then everything joined in case the number was split.
98
+ texts.push(lines.map((line) => line.text).join(""));
99
+
100
+ let shapeOnly: string | null = null;
101
+ for (const text of texts) {
102
+ const compact = text.toUpperCase().replace(/[^A-Z0-9]/g, "");
103
+ for (let start = 0; start + 9 <= compact.length; start += 1) {
104
+ const window = compact.slice(start, start + 9);
105
+ if (!NRIC_LIKE.test(window)) continue;
106
+ const candidate = fixCandidate(window);
107
+ const valid = isNricChecksumValid(candidate);
108
+ if (valid === true) return candidate;
109
+ if (valid === null && !shapeOnly) shapeOnly = candidate;
110
+ }
111
+ }
112
+ return shapeOnly;
113
+ }
114
+
115
+ /**
116
+ * A number with the NRIC's shape whose check letter does NOT match, or null.
117
+ *
118
+ * Never filled in on its own: the scanner only offers it ("Use anyway") once
119
+ * the same number has been read several times in a row, so a one-off misread
120
+ * never reaches the form but a worn card, or a specimen with a dummy number,
121
+ * does not leave the guard stuck.
122
+ */
123
+ export function findUncheckedNric(lines: CardLine[]): string | null {
124
+ const texts = lines.map((line) => line.text);
125
+ texts.push(lines.map((line) => line.text).join(""));
126
+ for (const text of texts) {
127
+ const compact = text.toUpperCase().replace(/[^A-Z0-9]/g, "");
128
+ for (let start = 0; start + 9 <= compact.length; start += 1) {
129
+ const window = compact.slice(start, start + 9);
130
+ if (!NRIC_LIKE.test(window)) continue;
131
+ const candidate = fixCandidate(window);
132
+ if (isNricChecksumValid(candidate) === false) return candidate;
133
+ }
134
+ }
135
+ return null;
136
+ }
137
+
138
+ const NAME_LABEL = /^\s*name\b[\s:.]*/i;
139
+ /** Labels that come after the name on the card: reading stops at them. */
140
+ const AFTER_NAME = /\b(race|date|birth|sex|country|identity|republic)\b/i;
141
+
142
+ /** Printed names are capitals: letters, spaces and a little punctuation. */
143
+ function asName(text: string): string | null {
144
+ const value = text.replace(/\s+/g, " ").trim();
145
+ if (!/^[A-Za-z][A-Za-z .,'@/()-]*$/.test(value)) return null;
146
+ const letters = value.replace(/[^A-Za-z]/g, "");
147
+ if (letters.length < 2) return null;
148
+ const capitals = letters.replace(/[^A-Z]/g, "").length;
149
+ // A few lower-case misreads are fine; mostly lower case is a label, not a name.
150
+ if (capitals / letters.length < 0.8) return null;
151
+ return value.toUpperCase();
152
+ }
153
+
154
+ /**
155
+ * The labels printed right under the name ("Race", "Date of birth", "Sex",
156
+ * "Country/Place of birth"), left-aligned with it. Not "Date of issue", which
157
+ * is on the back.
158
+ */
159
+ const UNDER_NAME = /\b(race|sex|country|date of b\w*|birth)\b/i;
160
+ /** Capitals on the card that are not the holder's name. */
161
+ const NOT_A_NAME = /\b(republic|singapore|identity|card|specimen)\b/i;
162
+
163
+ /**
164
+ * Race values printed on the card, under "Race". When the name line itself is
165
+ * not read, the Race value is the next line in capitals and was being taken
166
+ * for the name.
167
+ */
168
+ const RACE_VALUES = new Set([
169
+ "CHINESE",
170
+ "MALAY",
171
+ "INDIAN",
172
+ "EURASIAN",
173
+ "CAUCASIAN",
174
+ "FILIPINO",
175
+ "JAVANESE",
176
+ "BOYANESE",
177
+ "BUGIS",
178
+ "SIKH",
179
+ "ARAB",
180
+ "JAPANESE",
181
+ "KOREAN",
182
+ "THAI",
183
+ "VIETNAMESE",
184
+ "BURMESE",
185
+ "SINHALESE",
186
+ "PAKISTANI",
187
+ "BANGLADESHI",
188
+ "NEPALESE",
189
+ "INDONESIAN",
190
+ "OTHERS",
191
+ ]);
192
+
193
+ function nameOf(line: CardLine): string | null {
194
+ const name = asName(line.text);
195
+ if (!name || NOT_A_NAME.test(name) || RACE_VALUES.has(name)) return null;
196
+ return name;
197
+ }
198
+
199
+ /**
200
+ * The name from its first line, plus the lines it wraps onto: a long name
201
+ * wraps onto a second line set tight under the first; the name in another
202
+ * script below it has a wider gap and is not Latin.
203
+ */
204
+ function joinName(first: CardLine, following: CardLine[]): string {
205
+ const parts = [first];
206
+ for (const line of following) {
207
+ if (AFTER_NAME.test(line.text)) break;
208
+ const previous = parts[parts.length - 1];
209
+ const gap = line.y - (previous.y + previous.height);
210
+ if (nameOf(line) && gap < previous.height * 0.6 && parts.length < 3) {
211
+ parts.push(line);
212
+ } else break;
213
+ }
214
+ return parts
215
+ .map((line) => nameOf(line))
216
+ .join(" ")
217
+ .replace(/\s+/g, " ")
218
+ .trim();
219
+ }
220
+
221
+ /** The name under the "Name" label, starting at the label's left edge. */
222
+ function nameUnderLabel(sorted: CardLine[]): string | null {
223
+ const labelIndex = sorted.findIndex((line) => NAME_LABEL.test(line.text));
224
+ if (labelIndex < 0) return null;
225
+ const label = sorted[labelIndex];
226
+
227
+ // "Name TAN AH KOW" read as one line.
228
+ const sameLine = asName(label.text.replace(NAME_LABEL, ""));
229
+ if (sameLine && !AFTER_NAME.test(sameLine) && !NOT_A_NAME.test(sameLine)) {
230
+ return sameLine;
231
+ }
232
+
233
+ const tolerance = Math.max(label.height * 1.5, label.width * 0.5);
234
+ const below = sorted
235
+ .slice(labelIndex + 1)
236
+ .filter(
237
+ (line) => line.y > label.y && Math.abs(line.x - label.x) <= tolerance,
238
+ );
239
+ // The first line under the label must be the name; anything else ends it.
240
+ if (!below.length || AFTER_NAME.test(below[0].text) || !nameOf(below[0])) {
241
+ return null;
242
+ }
243
+ return joinName(below[0], below.slice(1));
244
+ }
245
+
246
+ /**
247
+ * Without the "Name" label (it is printed small and is often not read): the
248
+ * name is the first line in capitals in the column above "Race" / "Date of
249
+ * birth", which are left-aligned with it.
250
+ */
251
+ function nameAboveDetails(sorted: CardLine[]): string | null {
252
+ const details = sorted.filter((line) => UNDER_NAME.test(line.text));
253
+ if (!details.length) return null;
254
+ // The top row of those labels, its left-most: "Race" or "Date of birth",
255
+ // never "Sex" beside it.
256
+ const top = details[0];
257
+ const anchor = details
258
+ .filter((line) => Math.abs(line.y - top.y) <= top.height)
259
+ .reduce((left, line) => (line.x < left.x ? line : left));
260
+
261
+ const tolerance = anchor.height * 2;
262
+ const column = sorted.filter(
263
+ (line) => line.y < anchor.y && Math.abs(line.x - anchor.x) <= tolerance,
264
+ );
265
+ const firstIndex = column.findIndex((line) => nameOf(line) !== null);
266
+ if (firstIndex < 0) return null;
267
+ return joinName(column[firstIndex], column.slice(firstIndex + 1));
268
+ }
269
+
270
+ /** The holder's name off the front of the card, or null. */
271
+ export function findName(lines: CardLine[]): string | null {
272
+ const sorted = [...lines].sort((a, b) => a.y - b.y || a.x - b.x);
273
+ return nameUnderLabel(sorted) ?? nameAboveDetails(sorted);
274
+ }
275
+
276
+ export function readNricCard(lines: CardLine[]): NricCardRead {
277
+ return { nric: findNric(lines), name: findName(lines) };
278
+ }
@@ -268,7 +268,8 @@ export function buildSummary(
268
268
  now: Date = new Date(),
269
269
  ): string {
270
270
  const year = sgtParts(now).date.slice(0, 4);
271
- const areas = `${f.areaCount} area${f.areaCount === 1 ? "" : "s"}`;
271
+ // One extra check = exactly one more round per picked area each time it runs (not one per normal visit).
272
+ const areas = f.areaCount === 1 ? "1 area" : `each of ${f.areaCount} areas`;
272
273
  const units = `${unitCount} unit${unitCount === 1 ? "" : "s"}`;
273
274
  const next = nextRunDate(f, now);
274
275
  const nextText = next ? `${DAYS[weekdayOf(next)]} ${fmtDate(next, year)}` : "never";