@7365admin1/layer-common 4.107.0 → 4.109.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/assets/css/screens.css +11 -0
  3. package/components/BulletinBoardForm.vue +112 -0
  4. package/components/BulletinBoardView.vue +2 -0
  5. package/components/BulletinEventRsvp.vue +126 -0
  6. package/components/Nfc/NFCPatrolReportMain.vue +61 -72
  7. package/components/Nfc/NFCPatrolRouteMain.vue +21 -31
  8. package/components/Nfc/NFCRouteScheduleTimes.vue +83 -0
  9. package/components/Nfc/PatrolReport/DailyReportTab.vue +24 -3
  10. package/components/Nfc/PatrolReport/MonthlyReportTab.vue +32 -3
  11. package/components/Nfc/PatrolReport/MonthlyReportTable.vue +29 -1
  12. package/components/Nfc/PatrolReport/PatrolActivityTable.vue +24 -2
  13. package/components/Nfc/PatrolReport/PatrolMonthDialog.vue +195 -0
  14. package/components/Nfc/PatrolReport/PatrolReportTab.vue +11 -2
  15. package/components/Nfc/PatrolReport/PatrolRouteDetailsDialog.vue +214 -0
  16. package/components/Nfc/PatrolReport/ReportLocationHeader.vue +4 -1
  17. package/components/Nfc/PatrolReport/SkipPhotoDetails.vue +137 -0
  18. package/components/Nfc/PatrolReport/SkipPhotoThumb.vue +132 -0
  19. package/components/Nfc/PatrolReport/SummaryReportTable.vue +42 -1
  20. package/components/OnlineFormFill.vue +11 -0
  21. package/components/SiteSettings.vue +4 -0
  22. package/components/VehicleManagement.vue +4 -1
  23. package/composables/useNFCPatrolDailyReport.ts +4 -18
  24. package/composables/useNFCPatrolMonthlyReport.ts +34 -5
  25. package/composables/useNFCPatrolReport.ts +5 -74
  26. package/composables/useReportPdf.ts +220 -0
  27. package/package.json +1 -1
  28. package/types/bulletin-board.d.ts +11 -1
  29. package/types/nfc-patrol-report.ts +14 -0
  30. package/utils/nfc-patrol-report.ts +239 -1
  31. package/utils/nfc-patrol-schedule.ts +73 -0
  32. package/utils/online-form-submission.ts +48 -0
  33. package/utils/report-export.ts +120 -0
@@ -6,7 +6,12 @@ import type {
6
6
  } from "../types/nfc-patrol-report";
7
7
  import useNFCPatrolLog from "./useNFCPatrolLog";
8
8
  import useSite from "./useSite";
9
- import { collectAllPages, mapLogsToMonthlyRows } from "../utils/nfc-patrol-report";
9
+ import {
10
+ collectAllPages,
11
+ mapLogsToMonthlyRows,
12
+ mapLogsToMonthRuns,
13
+ monthIndexOf,
14
+ } from "../utils/nfc-patrol-report";
10
15
 
11
16
  interface NFCPatrolLogListResponse {
12
17
  items?: Record<string, any>[];
@@ -48,9 +53,15 @@ export function useNFCPatrolMonthlyReport(
48
53
  */
49
54
  const failed = ref(false);
50
55
 
56
+ // The year's patrol logs, kept so a month's row can open its list without
57
+ // asking the server again. Not deep-reactive: nothing edits them.
58
+ const logs = shallowRef<Record<string, any>[]>([]);
59
+ let siteTimezone: string | undefined;
60
+
51
61
  async function fetchReport() {
52
62
  if (!site || !filters.route) {
53
63
  report.value = null;
64
+ logs.value = [];
54
65
  notFound.value = false;
55
66
  failed.value = false;
56
67
  return;
@@ -63,7 +74,7 @@ export function useNFCPatrolMonthlyReport(
63
74
  try {
64
75
  // The whole year, not page one of it. `collectAllPages` reads the page
65
76
  // count out of the first reply and walks the rest.
66
- const [logs, siteInfo] = await Promise.all([
77
+ const [yearLogs, siteInfo] = await Promise.all([
67
78
  collectAllPages((page) =>
68
79
  getPatrolLogs({
69
80
  page,
@@ -77,8 +88,16 @@ export function useNFCPatrolMonthlyReport(
77
88
  getSiteById(site),
78
89
  ]);
79
90
 
80
- if (logs.length) {
81
- const routeName = logs[0]?.route?.name ?? "-";
91
+ logs.value = yearLogs;
92
+ siteTimezone = (siteInfo as { timezone?: string }).timezone;
93
+
94
+ if (yearLogs.length) {
95
+ const routeName = yearLogs[0]?.route?.name ?? "-";
96
+ const runs = Array<number>(12).fill(0);
97
+ for (const log of yearLogs) {
98
+ const month = monthIndexOf(log);
99
+ if (!Number.isNaN(month)) runs[month]++;
100
+ }
82
101
 
83
102
  report.value = {
84
103
  location: {
@@ -86,7 +105,10 @@ export function useNFCPatrolMonthlyReport(
86
105
  address: buildAddress(siteInfo.address),
87
106
  },
88
107
  title: `Patrol Route Summary: ${routeName}`,
89
- rows: mapLogsToMonthlyRows(logs),
108
+ rows: mapLogsToMonthlyRows(yearLogs).map((row, month) => ({
109
+ ...row,
110
+ runs: runs[month],
111
+ })),
90
112
  };
91
113
  } else {
92
114
  report.value = null;
@@ -94,6 +116,7 @@ export function useNFCPatrolMonthlyReport(
94
116
  }
95
117
  } catch {
96
118
  report.value = null;
119
+ logs.value = [];
97
120
  notFound.value = false;
98
121
  failed.value = true;
99
122
  } finally {
@@ -101,8 +124,14 @@ export function useNFCPatrolMonthlyReport(
101
124
  }
102
125
  }
103
126
 
127
+ /** The patrol runs of one month (0-11), in the order they ran. */
128
+ function monthRuns(monthIndex: number) {
129
+ return mapLogsToMonthRuns(logs.value, monthIndex, siteTimezone);
130
+ }
131
+
104
132
  return {
105
133
  report,
134
+ monthRuns,
106
135
  loading,
107
136
  notFound,
108
137
  failed,
@@ -5,12 +5,7 @@ import type {
5
5
  } from "../types/nfc-patrol-report";
6
6
  import useNFCPatrolLog from "./useNFCPatrolLog";
7
7
  import useSite from "./useSite";
8
- import {
9
- actualPatrolSpan,
10
- checkpointClock,
11
- clockTime,
12
- DEFAULT_PATROL_TIMEZONE,
13
- } from "../utils/nfc-patrol-time";
8
+ import { mapLogToPatrolRun } from "../utils/nfc-patrol-report";
14
9
 
15
10
  interface NFCPatrolLogListResponse {
16
11
  items?: Record<string, any>[];
@@ -18,69 +13,6 @@ interface NFCPatrolLogListResponse {
18
13
  pageRange?: string;
19
14
  }
20
15
 
21
- function formatDuration(totalMinutes: number) {
22
- if (totalMinutes < 60) return `${totalMinutes} mins`;
23
-
24
- const hrs = Math.floor(totalMinutes / 60);
25
- const mins = totalMinutes % 60;
26
-
27
- if (mins === 0) return `${hrs} hrs`;
28
- return `${hrs} hrs ${mins} mins`;
29
- }
30
-
31
- function diffMinutes(startIso?: string | Date, endIso?: string | Date) {
32
- if (!startIso || !endIso) return "-";
33
- const totalMinutes = Math.max(
34
- 0,
35
- Math.round((new Date(endIso).getTime() - new Date(startIso).getTime()) / 60000),
36
- );
37
- return formatDuration(totalMinutes);
38
- }
39
-
40
-
41
- function mapLogToPatrolReport(
42
- log: Record<string, any>,
43
- location: NFCPatrolReport["location"],
44
- siteTimezone?: string,
45
- ): NFCPatrolReport {
46
- const checkpoints: Record<string, any>[] = log.checkPoints ?? [];
47
- const timezone = log.timezone || siteTimezone || DEFAULT_PATROL_TIMEZONE;
48
- const actual = actualPatrolSpan(log);
49
-
50
- const activities: NFCPatrolActivity[] = checkpoints.map((cp) => {
51
- const clock = checkpointClock(log, cp, siteTimezone);
52
- return {
53
- id: cp.nfcTag_id,
54
- checkpoint: cp.nfcTag_name,
55
- guardName: log.sign?.guardName,
56
- guardSignatureFileId: log.sign?.guardSignatureFileId,
57
- startTime: clock.start,
58
- endTime: clock.end,
59
- status: cp.status,
60
- remarks: cp.skippedRemarks && cp.skippedRemarks !== "N/A" ? cp.skippedRemarks : undefined,
61
- };
62
- });
63
-
64
- return {
65
- location,
66
- routeSummary: {
67
- routeName: log.route?.name,
68
- tolerance: Number(log.route?.tolerance) || 0,
69
- schedule: {
70
- start: log.route?.startTime,
71
- end: log.route?.endTime,
72
- totalTime: diffMinutes(log.startDateTime, log.endDateTime),
73
- },
74
- actual: {
75
- start: clockTime(actual.start, timezone),
76
- end: clockTime(actual.end, timezone),
77
- totalTime: diffMinutes(actual.start, actual.end),
78
- },
79
- },
80
- activities,
81
- };
82
- }
83
-
84
16
  export function useNFCPatrolReport(
85
17
  filters: NFCPatrolReportFilters,
86
18
  site: string,
@@ -128,14 +60,13 @@ export function useNFCPatrolReport(
128
60
  const log = logsRes.items?.[0];
129
61
 
130
62
  if (log) {
131
- report.value = mapLogToPatrolReport(
132
- log,
133
- {
63
+ report.value = {
64
+ location: {
134
65
  name: siteInfo.name,
135
66
  address: buildAddress(siteInfo.address),
136
67
  },
137
- (siteInfo as { timezone?: string }).timezone,
138
- );
68
+ ...mapLogToPatrolRun(log, (siteInfo as { timezone?: string }).timezone),
69
+ };
139
70
  } else {
140
71
  report.value = null;
141
72
  notFound.value = true;
@@ -0,0 +1,220 @@
1
+ import html2canvas from "html2canvas";
2
+ import jsPDF from "jspdf";
3
+ import { formatGeneratedAt, planPdfPages, type PdfTable } from "../utils/report-export";
4
+
5
+ export type ReportPdfMeta = {
6
+ /** "Patrol Report" */
7
+ title: string;
8
+ site?: string;
9
+ address?: string;
10
+ /** Shown top right of every page: Route, Date, Time... */
11
+ details: Array<{ label: string; value: string }>;
12
+ fileName: string;
13
+ };
14
+
15
+ /**
16
+ * A report area (`.report-export`) as an A4 PDF.
17
+ *
18
+ * The area is drawn the way the old export drew it - html2canvas, one tall
19
+ * image - but onto a page rather than across it:
20
+ *
21
+ * - laid out at one width whatever the screen, so a PDF from a laptop and
22
+ * one from a 4K monitor read the same, and always in the light theme;
23
+ * - inside margins, under a header (report, site, route, date) and over a
24
+ * footer (when it was made, page X of Y) drawn as real text on every page;
25
+ * - cut only between rows, photos and sections (`planPdfPages`), repeating a
26
+ * table's header row on the page it carries on to;
27
+ * - without the on-screen site card and dividers: the header says it.
28
+ *
29
+ * Everything PDF-only happens on html2canvas's clone of the page, so the
30
+ * screen never changes. `report-export--pdf` marks that clone: under it
31
+ * `data-pdf-only` shows (hidden on screen by screens.css) and `data-pdf-hide`
32
+ * goes. A row marked `data-pdf-keep-with-next` is never the last on a page,
33
+ * so a checkpoint stays with the skip photo drawn under it.
34
+ */
35
+
36
+ const RENDER_WIDTH = 1000; // CSS px the report is laid out at for the PDF
37
+ const SCALE = 2;
38
+
39
+ // A4 portrait, in mm.
40
+ const PAGE_W = 210;
41
+ const PAGE_H = 297;
42
+ const MARGIN = 12;
43
+ const CONTENT_TOP = 38;
44
+ const CONTENT_BOTTOM = PAGE_H - MARGIN - 9;
45
+ const CONTENT_W = PAGE_W - MARGIN * 2;
46
+ const DETAILS_LABEL_X = 134;
47
+ const DETAILS_VALUE_X = 150;
48
+
49
+ const INK: [number, number, number] = [20, 22, 26];
50
+ const MUTED: [number, number, number] = [100, 104, 112];
51
+ const RULE: [number, number, number] = [220, 222, 226];
52
+
53
+ const PDF_CSS = `
54
+ .report-export--pdf { width: ${RENDER_WIDTH}px !important; max-width: none !important; background: #fff; }
55
+ .report-export--pdf > .v-divider, .report-export--pdf [data-pdf-hide] { display: none !important; }
56
+ .report-export--pdf tr[data-pdf-only] { display: table-row !important; }
57
+ .report-export--pdf [data-pdf-only]:not(tr) { display: block !important; }
58
+ .report-export--pdf tr[data-pdf-keep-with-next] > td { border-bottom-color: transparent !important; }
59
+ .report-export--pdf .table-card, .report-export--pdf .app-card { box-shadow: none !important; }
60
+ .report-export--pdf .v-table__wrapper { overflow: visible !important; }
61
+ .report-export--pdf tbody tr:hover > td { background: transparent !important; }
62
+ /* html2canvas draws digits at their plain widths but places them where the
63
+ page laid out tabular ones, so "10:33" came out "10 :33". */
64
+ .report-export--pdf * { font-variant-numeric: normal !important; }
65
+ `;
66
+
67
+ /** Where the report can be cut, and where its tables are, measured on the clone. */
68
+ function measure(clone: HTMLElement) {
69
+ const origin = clone.getBoundingClientRect().top;
70
+ const top = (el: Element) => el.getBoundingClientRect().top - origin;
71
+ const bottom = (el: Element) => el.getBoundingClientRect().bottom - origin;
72
+
73
+ // Between sections, between table rows (the last row ends with its card,
74
+ // and a row stays with the skip photos under it), and between lines of
75
+ // skip photos. Never right under a heading.
76
+ const breaks = [
77
+ ...Array.from(clone.children),
78
+ ...Array.from(clone.querySelectorAll("tbody tr:not(:last-child):not([data-pdf-keep-with-next])")),
79
+ ...Array.from(clone.querySelectorAll(".skip-details__item")),
80
+ ].map(bottom);
81
+
82
+ const tables: PdfTable[] = Array.from(clone.querySelectorAll("table")).flatMap((table) => {
83
+ const head = table.querySelector("thead");
84
+ return head
85
+ ? [{ top: top(table), bottom: bottom(table), head: { top: top(head), bottom: bottom(head) } }]
86
+ : [];
87
+ });
88
+
89
+ return { height: clone.getBoundingClientRect().height, breaks, tables };
90
+ }
91
+
92
+ function drawHeader(pdf: jsPDF, meta: ReportPdfMeta) {
93
+ pdf.setTextColor(...INK);
94
+ pdf.setFont("helvetica", "bold");
95
+ pdf.setFontSize(15);
96
+ pdf.text(meta.title, MARGIN, MARGIN + 5);
97
+
98
+ let y = MARGIN + 11.5;
99
+ if (meta.site) {
100
+ pdf.setFontSize(10);
101
+ pdf.text(pdf.splitTextToSize(meta.site, DETAILS_LABEL_X - MARGIN - 6)[0], MARGIN, y);
102
+ y += 4.5;
103
+ }
104
+ if (meta.address) {
105
+ pdf.setFont("helvetica", "normal");
106
+ pdf.setFontSize(8.5);
107
+ pdf.setTextColor(...MUTED);
108
+ pdf.text(pdf.splitTextToSize(meta.address, DETAILS_LABEL_X - MARGIN - 6).slice(0, 2), MARGIN, y);
109
+ }
110
+
111
+ let dy = MARGIN + 4;
112
+ for (const { label, value } of meta.details) {
113
+ pdf.setFont("helvetica", "normal");
114
+ pdf.setFontSize(8.5);
115
+ pdf.setTextColor(...MUTED);
116
+ pdf.text(label, DETAILS_LABEL_X, dy);
117
+
118
+ const lines: string[] = pdf.splitTextToSize(value || "-", PAGE_W - MARGIN - DETAILS_VALUE_X);
119
+ pdf.setFont("helvetica", "bold");
120
+ pdf.setTextColor(...INK);
121
+ pdf.text(lines.length > 1 ? `${lines[0].replace(/\s+$/, "")}…` : lines[0], DETAILS_VALUE_X, dy);
122
+ dy += 5;
123
+ }
124
+
125
+ pdf.setDrawColor(...RULE);
126
+ pdf.setLineWidth(0.3);
127
+ pdf.line(MARGIN, CONTENT_TOP - 4, PAGE_W - MARGIN, CONTENT_TOP - 4);
128
+ }
129
+
130
+ function drawFooter(pdf: jsPDF, page: number, pages: number, generatedAt: string) {
131
+ const y = PAGE_H - MARGIN;
132
+ pdf.setDrawColor(...RULE);
133
+ pdf.setLineWidth(0.3);
134
+ pdf.line(MARGIN, y - 5, PAGE_W - MARGIN, y - 5);
135
+
136
+ pdf.setFont("helvetica", "normal");
137
+ pdf.setFontSize(8);
138
+ pdf.setTextColor(...MUTED);
139
+ pdf.text(`Generated ${generatedAt}`, MARGIN, y);
140
+ pdf.text(`Page ${page} of ${pages}`, PAGE_W - MARGIN, y, { align: "right" });
141
+ }
142
+
143
+ export default function useReportPdf() {
144
+ async function exportReportPdf(report: HTMLElement, meta: ReportPdfMeta) {
145
+ let layout = { height: 0, breaks: [] as number[], tables: [] as PdfTable[] };
146
+
147
+ const canvas = await html2canvas(report, {
148
+ scale: SCALE,
149
+ useCORS: true,
150
+ backgroundColor: "#fff",
151
+ windowWidth: Math.max(window.innerWidth, RENDER_WIDTH + 200),
152
+ onclone: (doc, clone) => {
153
+ // A PDF is read on white: the dark theme's tokens hang off this class.
154
+ doc.querySelectorAll(".v-theme--dark").forEach((el) => {
155
+ el.classList.replace("v-theme--dark", "v-theme--light");
156
+ });
157
+ const style = doc.createElement("style");
158
+ style.textContent = PDF_CSS;
159
+ doc.head.appendChild(style);
160
+ clone.classList.add("report-export--pdf");
161
+ layout = measure(clone);
162
+ },
163
+ });
164
+
165
+ // Canvas px per report px, and report px per mm on the page.
166
+ const k = canvas.height / layout.height;
167
+ const pxPerMm = canvas.width / k / CONTENT_W;
168
+ const pages = planPdfPages(
169
+ layout.height,
170
+ (CONTENT_BOTTOM - CONTENT_TOP) * pxPerMm,
171
+ layout.breaks,
172
+ layout.tables,
173
+ );
174
+
175
+ const pdf = new jsPDF({ unit: "mm", format: "a4", orientation: "portrait" });
176
+
177
+ pages.forEach((page, index) => {
178
+ if (index > 0) pdf.addPage();
179
+
180
+ const headPx = page.head ? page.head.bottom - page.head.top : 0;
181
+ const bodyPx = page.bottom - page.top;
182
+ const part = document.createElement("canvas");
183
+ part.width = canvas.width;
184
+ part.height = Math.max(1, Math.round((headPx + bodyPx) * k));
185
+
186
+ const ctx = part.getContext("2d");
187
+ if (!ctx) return;
188
+ ctx.fillStyle = "#fff";
189
+ ctx.fillRect(0, 0, part.width, part.height);
190
+ if (page.head) {
191
+ const h = Math.round(headPx * k);
192
+ ctx.drawImage(canvas, 0, Math.round(page.head.top * k), canvas.width, h, 0, 0, canvas.width, h);
193
+ }
194
+ const bodyTop = Math.round(page.top * k);
195
+ const bodyH = Math.round(page.bottom * k) - bodyTop;
196
+ ctx.drawImage(canvas, 0, bodyTop, canvas.width, bodyH, 0, Math.round(headPx * k), canvas.width, bodyH);
197
+
198
+ pdf.addImage(
199
+ part.toDataURL("image/jpeg", 0.92),
200
+ "JPEG",
201
+ MARGIN,
202
+ CONTENT_TOP,
203
+ CONTENT_W,
204
+ part.height / k / pxPerMm,
205
+ );
206
+ });
207
+
208
+ const generatedAt = formatGeneratedAt();
209
+ const total = pdf.getNumberOfPages();
210
+ for (let page = 1; page <= total; page++) {
211
+ pdf.setPage(page);
212
+ drawHeader(pdf, meta);
213
+ drawFooter(pdf, page, total, generatedAt);
214
+ }
215
+
216
+ pdf.save(meta.fileName);
217
+ }
218
+
219
+ return { exportReportPdf };
220
+ }
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.107.0",
5
+ "version": "4.109.0",
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.",
@@ -12,6 +12,16 @@ declare interface TAnnouncement {
12
12
  startDate: string;
13
13
  endDate: string;
14
14
  status: TAnnouncementStatus
15
+ event?: TAnnouncementEvent | null;
16
+ }
17
+
18
+ declare interface TAnnouncementEvent {
19
+ startAt: string;
20
+ endAt?: string | null;
21
+ venue?: string;
22
+ rsvp?: boolean;
23
+ rsvpClosesAt?: string | null;
24
+ capacity?: number | null;
15
25
  }
16
26
 
17
27
  declare interface TAnnouncementFile {
@@ -24,6 +34,6 @@ declare type TAnnouncementRecipients = "resident" | "security_agency" | "cleanin
24
34
 
25
35
 
26
36
 
27
- declare type TCreateAnnouncementPayload = Pick<TAnnouncement, "recipients" | "file" | "site" | "noExpiration" | "startDate" | "endDate" | "content" | "title">
37
+ declare type TCreateAnnouncementPayload = Pick<TAnnouncement, "recipients" | "file" | "site" | "noExpiration" | "startDate" | "endDate" | "content" | "title"> & { event?: TAnnouncementEvent | null }
28
38
 
29
39
  declare type TAnnouncementStatus = "active" | "expired"
@@ -53,6 +53,14 @@ export interface NFCPatrolActivity {
53
53
  endTime: string;
54
54
  status: NFCPatrolActivityStatus;
55
55
  remarks?: string;
56
+ /** Photo the guard attached when skipping (an uploaded file's id). */
57
+ skippedPhotoFileId?: string;
58
+ }
59
+
60
+ /** One patrol run: its schedule-vs-actual and its checkpoints. */
61
+ export interface NFCPatrolRunDetail {
62
+ routeSummary: NFCPatrolRouteSummary;
63
+ activities: NFCPatrolActivity[];
56
64
  }
57
65
 
58
66
  export interface NFCPatrolReport {
@@ -65,6 +73,8 @@ export type NFCPatrolReportSummaryStatus = "Completed" | "Incomplete";
65
73
 
66
74
  export interface NFCPatrolReportSummaryRow {
67
75
  id: string;
76
+ /** The patrol's day, "YYYY-MM-DD". */
77
+ date?: string;
68
78
  routeName: string;
69
79
  securityCheckSchedule: string;
70
80
  guardName?: string;
@@ -72,6 +82,8 @@ export interface NFCPatrolReportSummaryRow {
72
82
  checked: number;
73
83
  missed: number;
74
84
  status: NFCPatrolReportSummaryStatus;
85
+ /** The run behind the row, opened in the route details dialog. */
86
+ detail?: NFCPatrolRunDetail;
75
87
  }
76
88
 
77
89
  export interface NFCPatrolSummaryReport {
@@ -82,6 +94,8 @@ export interface NFCPatrolSummaryReport {
82
94
 
83
95
  export interface NFCPatrolMonthlyReportRow {
84
96
  month: string;
97
+ /** Patrol runs that month; a month with none has no list to open. */
98
+ runs?: number;
85
99
  checked: number;
86
100
  /** `null` when no checkpoint was due that month - see `utils/nfc-patrol-report.ts`. */
87
101
  checkedPercentage: number | null;