@7365admin1/layer-common 4.136.0 → 4.137.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.
@@ -5,10 +5,52 @@
5
5
  :org-id="orgId"
6
6
  :site="site"
7
7
  :service-type="serviceType"
8
- text="An extra visit adds one more round to your daily schedule at the time you choose, once or on a repeat. Staff on this site get a push notification when it is added."
9
- />
8
+ text="An extra visit adds one more round to the Daily Checklist at a time you choose - once, or on a repeat."
9
+ how-title="How extra visits work"
10
+ how-key="extra-visits"
11
+ >
12
+ <ol class="sg-steps">
13
+ <li>
14
+ Every night the app makes the <strong>Daily Checklist</strong>
15
+ for every area on this site. You do not need to do anything for
16
+ that.
17
+ </li>
18
+ <li>
19
+ An <strong>extra visit</strong> adds one more round to that
20
+ checklist, in the areas you pick, at the time you set - once, or
21
+ repeating (every day, weekdays, chosen days, or monthly).
22
+ </li>
23
+ <li>
24
+ At that time, staff see the extra round in their mobile app's
25
+ daily checklist (with a push notification) and tick each unit as
26
+ they finish it.
27
+ </li>
28
+ <li>
29
+ Here and in the Daily Checklist you see how it went:
30
+ <strong>Done</strong>, <strong>In progress</strong>,
31
+ <strong>Needs redo</strong> or <strong>Missed</strong>.
32
+ </li>
33
+ </ol>
34
+ <div class="sg-example">
35
+ <strong>Example:</strong> the lobby toilets get busy at lunch. Add
36
+ an extra visit called "Lunch toilet check", pick the area "Lobby
37
+ Toilets", set 2:00 pm, every weekday. From the next weekday, staff
38
+ get one more toilet round at 2:00 pm, on top of their normal ones.
39
+ </div>
40
+ <div class="sg-h">What the words mean</div>
41
+ <dl class="sg-legend">
42
+ <template v-for="h in STATUS_HELP" :key="h.word">
43
+ <dt><StatusChip :status="h.word" :label="h.word" /></dt>
44
+ <dd>{{ h.meaning }}</dd>
45
+ </template>
46
+ <template v-for="h in RESULT_HELP" :key="'r' + h.word">
47
+ <dt><StatusChip :status="h.word" :label="h.word" /></dt>
48
+ <dd>Last visit: {{ h.meaning }}</dd>
49
+ </template>
50
+ </dl>
51
+ </ScheduleGuide>
10
52
  <TableMain
11
- :title="'Extra Visits (Scheduled)'"
53
+ :title="'Extra Visits'"
12
54
  :items="items"
13
55
  :headers="headers"
14
56
  :loading="loading"
@@ -26,9 +68,14 @@
26
68
  <div class="st-empty">
27
69
  <strong>No extra visits yet.</strong>
28
70
  <span>
29
- An extra visit adds one more round to your daily
30
- {{ scheduleName }} at a time you choose. Set up your Areas and
31
- Units first, then add one.
71
+ Your Daily Checklist already covers the normal rounds. Add an
72
+ extra visit when an area needs one more round at a set time -
73
+ for example "Lunch toilet check, Lobby Toilets, 2:00 pm every
74
+ weekday".
75
+ </span>
76
+ <span v-if="canCreateScheduleTask">
77
+ Press <strong>Add Extra Visit</strong> above to start. Your
78
+ Areas and Units must be set up first.
32
79
  </span>
33
80
  </div>
34
81
  </template>
@@ -61,27 +108,6 @@
61
108
  </v-row>
62
109
  </template>
63
110
 
64
- <template #item.areas="{ value }">
65
- <div v-if="value && value.length > 0">
66
- <StatusChip
67
- v-for="(area, idx) in value.slice(0, 2)"
68
- :key="idx"
69
- class="mr-1 mb-1"
70
- :dot="false"
71
- tone="neutral"
72
- :label="area.name"
73
- />
74
- <StatusChip
75
- v-if="value.length > 2"
76
- class="mb-1"
77
- :dot="false"
78
- tone="neutral"
79
- :label="`+${value.length - 2} more`"
80
- />
81
- </div>
82
- <span v-else>N/A</span>
83
- </template>
84
-
85
111
  <template #item.when="{ item }">
86
112
  <span class="st-when">{{ item.whenText }}</span>
87
113
  </template>
@@ -91,24 +117,34 @@
91
117
  </template>
92
118
 
93
119
  <template #item.lastRun="{ item }">
94
- <StatusChip
95
- v-if="item.lastRun && item.lastRunStatus === 'done'"
96
- status="Done"
97
- :label="item.lastRunText"
98
- />
99
- <StatusChip
100
- v-else-if="item.lastRun && item.lastRunStatus === 'missed'"
101
- status="Missed"
102
- :label="item.lastRunText"
103
- />
104
- <span v-else>{{ item.lastRunText }}</span>
120
+ <div class="st-cell">
121
+ <StatusChip
122
+ v-if="item.lastRun && item.lastRunWord"
123
+ :status="item.lastRunWord"
124
+ :label="item.lastRunText"
125
+ />
126
+ <span v-else>{{ item.lastRunText }}</span>
127
+ <a
128
+ v-if="item.lastRun"
129
+ href="#"
130
+ class="st-link"
131
+ @click.prevent.stop="openChecklist(item)"
132
+ >
133
+ See that day's checklist
134
+ </a>
135
+ </div>
105
136
  </template>
106
137
 
107
138
  <template #item.status="{ item }">
108
- <StatusChip
109
- :status="item.stateLabel"
110
- :label="item.stateLabel"
111
- />
139
+ <div class="st-cell">
140
+ <StatusChip
141
+ :status="item.stateLabel"
142
+ :label="item.stateLabel"
143
+ />
144
+ <span v-if="item.stateKey === 'ended'" class="st-note">
145
+ {{ item.finishedText }}
146
+ </span>
147
+ </div>
112
148
  </template>
113
149
 
114
150
  <template #item.actions="{ item }">
@@ -123,6 +159,17 @@
123
159
  >
124
160
  Edit
125
161
  </v-btn>
162
+ <v-btn
163
+ v-if="canUpdateScheduleTask && item.stateKey === 'ended'"
164
+ size="small"
165
+ variant="tonal"
166
+ color="primary"
167
+ class="text-none"
168
+ prepend-icon="mdi-calendar-refresh"
169
+ @click.stop="onExtendRow(item)"
170
+ >
171
+ Extend or repeat
172
+ </v-btn>
126
173
  <v-btn
127
174
  v-if="canUpdateScheduleTask && item.stateKey !== 'ended'"
128
175
  size="small"
@@ -155,6 +202,7 @@
155
202
  v-model:show="showForm"
156
203
  :mode="formMode"
157
204
  :taskData="selectedTask"
205
+ :start-step="formStartStep"
158
206
  :site="props.site"
159
207
  :serviceType="props.serviceType"
160
208
  @refresh="getTaskRefresh"
@@ -194,7 +242,7 @@
194
242
 
195
243
  <v-row no-gutters class="mb-2">
196
244
  <v-col cols="5" class="text-subtitle-2 font-weight-bold">
197
- Next run:
245
+ Next visit:
198
246
  </v-col>
199
247
  <v-col cols="7" class="text-subtitle-2">
200
248
  {{ selectedNext || "-" }}
@@ -203,10 +251,18 @@
203
251
 
204
252
  <v-row no-gutters class="mb-2">
205
253
  <v-col cols="5" class="text-subtitle-2 font-weight-bold">
206
- Last run:
254
+ Last visit:
207
255
  </v-col>
208
256
  <v-col cols="7" class="text-subtitle-2">
209
257
  {{ lastRunLabel(selectedTask) }}
258
+ <a
259
+ v-if="selectedTask.lastRun"
260
+ href="#"
261
+ class="st-link d-block"
262
+ @click.prevent="openChecklist(selectedTask)"
263
+ >
264
+ See that day's checklist
265
+ </a>
210
266
  </v-col>
211
267
  </v-row>
212
268
 
@@ -319,7 +375,7 @@
319
375
  </div>
320
376
  <span v-else class="text-caption text-disabled"
321
377
  >No units in this area, so it is skipped when
322
- this visit runs.</span
378
+ this visit is added.</span
323
379
  >
324
380
  </v-card-text>
325
381
  </div>
@@ -348,6 +404,12 @@
348
404
  :status="stateOf(selectedTask).label"
349
405
  :label="stateOf(selectedTask).label"
350
406
  />
407
+ <div
408
+ v-if="stateOf(selectedTask).key === 'ended'"
409
+ class="st-note mt-1"
410
+ >
411
+ {{ finishedReason(selectedTask as any) }}
412
+ </div>
351
413
  </v-col>
352
414
  </v-row>
353
415
 
@@ -383,8 +445,8 @@
383
445
  </v-card-title>
384
446
 
385
447
  <v-card-text class="screen-modal__body">
386
- This stops all future runs of this extra visit. Visits that already
387
- happened are kept. Delete it?
448
+ This stops all future extra visits from it. Visits that already
449
+ happened stay on their Daily Checklists. Delete it?
388
450
  </v-card-text>
389
451
 
390
452
  <div class="screen-modal__footer">
@@ -422,8 +484,12 @@ import useScheduleTask from "../composables/useScheduleTask";
422
484
  import useUtils from "../composables/useUtils";
423
485
  import useAreas from "../composables/useAreas";
424
486
  import {
487
+ RESULT_HELP,
488
+ STATUS_HELP,
425
489
  STATUS_LABELS,
490
+ describeRow,
426
491
  describeWhen,
492
+ finishedReason,
427
493
  fmtMoment,
428
494
  lastRunLabel,
429
495
  scheduleNameFor,
@@ -458,7 +524,16 @@ const canViewScheduleTaskDetails = computed(
458
524
  () => props.canViewScheduleTaskDetails
459
525
  );
460
526
 
461
- const scheduleName = scheduleNameFor(props.serviceType).title;
527
+ const checklistRoute = scheduleNameFor(props.serviceType).route;
528
+
529
+ /** Opens the Daily Checklist filtered to the day this extra visit was last added. */
530
+ function openChecklist(row: Record<string, any>) {
531
+ const day = String(row?.lastRun || "").slice(0, 10);
532
+ navigateTo({
533
+ path: `/${props.orgId}/${props.site}/${checklistRoute}`,
534
+ query: /^\d{4}-\d{2}-\d{2}$/.test(day) ? { date: day } : {},
535
+ });
536
+ }
462
537
 
463
538
  const page = ref(1);
464
539
  const pages = ref(0);
@@ -468,6 +543,7 @@ const searchInput = ref("");
468
543
  const showForm = ref(false);
469
544
  const selectedTask = ref<Record<string, any>>({});
470
545
  const formMode = ref<"add" | "edit">("add");
546
+ const formStartStep = ref(1);
471
547
  const submitting = ref(false);
472
548
  const dialogShowMoreActions = ref(false);
473
549
  const dialogDeleteTask = ref(false);
@@ -517,11 +593,10 @@ watch(dialogShowMoreActions, async (isOpen) => {
517
593
  const items = ref<any[]>([]);
518
594
 
519
595
  const headers = [
520
- { title: "Title", value: "title" },
521
- { title: "Areas", value: "areas" },
522
- { title: "When", value: "when", sortable: false },
523
- { title: "Next run", value: "nextRun", sortable: false },
524
- { title: "Last run", value: "lastRun", sortable: false },
596
+ { title: "Extra visit", value: "title" },
597
+ { title: "What will happen", value: "when", sortable: false },
598
+ { title: "Next visit", value: "nextRun", sortable: false },
599
+ { title: "Last visit", value: "lastRun", sortable: false },
525
600
  { title: "Status", value: "status", sortable: false },
526
601
  { title: "", value: "actions", sortable: false, align: "end" as const },
527
602
  ];
@@ -566,7 +641,9 @@ watchEffect(() => {
566
641
  const st = stateOf(row);
567
642
  return {
568
643
  ...row,
569
- whenText: describeWhen(row),
644
+ whenText: describeRow(row),
645
+ finishedText: finishedReason(row),
646
+ lastRunWord: ({ done: "Done", missed: "Missed", pending: "In progress" } as Record<string, string>)[row.lastRunStatus] || "",
570
647
  nextRunText: fmtMoment(row.nextRun),
571
648
  lastRunText: lastRunLabel(row),
572
649
  stateKey: st.key,
@@ -590,6 +667,7 @@ watch(
590
667
 
591
668
  function openFormDialog() {
592
669
  formMode.value = "add";
670
+ formStartStep.value = 1;
593
671
  selectedTask.value = {};
594
672
  showForm.value = true;
595
673
  }
@@ -597,6 +675,15 @@ function openFormDialog() {
597
675
  function onEditRow(item: Record<string, any>) {
598
676
  selectedTask.value = item;
599
677
  formMode.value = "edit";
678
+ formStartStep.value = 1;
679
+ showForm.value = true;
680
+ }
681
+
682
+ // A finished extra visit: open its "When" step so a new end date or repeat is one change away.
683
+ function onExtendRow(item: Record<string, any>) {
684
+ selectedTask.value = item;
685
+ formMode.value = "edit";
686
+ formStartStep.value = 3;
600
687
  showForm.value = true;
601
688
  }
602
689
 
@@ -612,8 +699,8 @@ async function togglePause(item: Record<string, any>) {
612
699
  await setScheduleTaskPaused(item._id, !resuming);
613
700
  showMessage(
614
701
  resuming
615
- ? "Resumed. It will run again from its next date."
616
- : "Paused. It will not run until you resume it.",
702
+ ? "Resumed. Extra visits are added again from its next date."
703
+ : "Paused. No extra visits are added until you resume it.",
617
704
  "success",
618
705
  );
619
706
  await getTaskRefresh();
@@ -649,6 +736,7 @@ async function onRowClick(data: any) {
649
736
  const onEditFromMoreAction = async () => {
650
737
  dialogShowMoreActions.value = false;
651
738
  formMode.value = "edit";
739
+ formStartStep.value = stateOf(selectedTask.value).key === "ended" ? 3 : 1;
652
740
  showForm.value = true;
653
741
  };
654
742
 
@@ -693,8 +781,27 @@ async function _deleteScheduleTask() {
693
781
  color: var(--text2);
694
782
  }
695
783
  .st-when {
784
+ display: inline-block;
785
+ min-width: 220px;
696
786
  white-space: normal;
697
787
  }
788
+ .st-cell {
789
+ display: flex;
790
+ flex-direction: column;
791
+ align-items: flex-start;
792
+ gap: 4px;
793
+ }
794
+ .st-note {
795
+ font-size: var(--fs-chip-lg);
796
+ color: var(--text2);
797
+ white-space: normal;
798
+ }
799
+ .st-link {
800
+ font-size: var(--fs-chip-lg);
801
+ font-weight: 600;
802
+ color: rgb(var(--v-theme-primary));
803
+ text-decoration: underline;
804
+ }
698
805
  .st-actions {
699
806
  display: flex;
700
807
  justify-content: flex-end;
@@ -104,7 +104,7 @@ const toneClass = computed(() =>
104
104
  font-size: var(--fs-chip);
105
105
  }
106
106
 
107
- /* The role tag: `padding:3px 10px;border-radius:999px;font-size:11.5px`. */
107
+ /* The role tag: `padding:3px 10px;border-radius:999px;font-size:12.5px`. */
108
108
  .status-chip--pill {
109
109
  padding: 3px 10px;
110
110
  font-size: var(--fs-chip);
@@ -397,12 +397,12 @@ onMounted(() => {
397
397
 
398
398
  :deep(.v-data-table thead tr th) {
399
399
  padding: 6px !important;
400
- font-size: 11px !important;
400
+ font-size: 12px !important;
401
401
  }
402
402
 
403
403
  :deep(.v-data-table tbody tr td) {
404
404
  padding: 4px !important;
405
- font-size: 10px !important;
405
+ font-size: 12px !important;
406
406
  }
407
407
 
408
408
  :deep(.v-img) {
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.136.0",
5
+ "version": "4.137.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.",
@@ -25,6 +25,13 @@ const ACRONYMS: Record<string, string> = {
25
25
  const RENAMED: [string, string][] = [
26
26
  ["Building Mgmt", "Occupancy Mgmt"],
27
27
  ["Event Mgmt", "Task Calendar"],
28
+ // Schedule screens (owner 21 Oct): the same names the menu and the mobile apps use.
29
+ ["Schedule Task", "Extra Visits"],
30
+ ...["Cleaning", "Technician", "Pest", "Pool", "Landscape"].flatMap((t): [string, string][] => [
31
+ [`${t} Schedule Area`, "Area"],
32
+ [`${t} Schedule Unit`, "Unit"],
33
+ [`${t} Schedule`, "Daily Checklist"],
34
+ ]),
28
35
  ];
29
36
 
30
37
  export function moduleNameFromRoute(route: {
@@ -50,6 +50,8 @@ export const NAV_SECTIONS: Record<string, string> = {
50
50
  Area: "SERVICE DELIVERY",
51
51
  Unit: "SERVICE DELIVERY",
52
52
  "Schedule Task": "SERVICE DELIVERY",
53
+ "Daily Checklist": "SERVICE DELIVERY",
54
+ "Extra Visits": "SERVICE DELIVERY",
53
55
 
54
56
  // EQUIPMENT
55
57
  "Equipment Management": "EQUIPMENT",
@@ -176,12 +176,20 @@ export function validateSchedule(f: TScheduleForm, now: Date = new Date()): TSch
176
176
  return e;
177
177
  }
178
178
 
179
- /** "at 08:00 every weekday from 21 Oct until 31 Dec" - the When, as a sentence fragment. */
179
+ /** "08:00" -> "8:00 am", "14:30" -> "2:30 pm". Anything else is returned as given. */
180
+ export function fmtTime(time: string | null | undefined): string {
181
+ const m = TIME_RE.exec(time || "");
182
+ if (!m) return time || "";
183
+ const h = Number(m[1]);
184
+ return `${h % 12 || 12}:${m[2]} ${h < 12 ? "am" : "pm"}`;
185
+ }
186
+
187
+ /** "every weekday at 8:00 am from 21 Oct until 31 Dec" - the When, as a sentence fragment. */
180
188
  export function describeWhen(
181
189
  t: Partial<TRule> & { time?: string },
182
190
  nowYear?: string,
183
191
  ): string {
184
- const time = t.time ? `at ${t.time} ` : "";
192
+ const at = t.time ? ` at ${fmtTime(t.time)}` : "";
185
193
  const repeat = t.repeat || "none";
186
194
  const dates = t.dates || [];
187
195
  const range =
@@ -189,23 +197,23 @@ export function describeWhen(
189
197
  (t.endDate ? ` until ${fmtDate(t.endDate, nowYear)}` : "");
190
198
  switch (repeat) {
191
199
  case "daily":
192
- return `${time}every day${range}`;
200
+ return `every day${at}${range}`;
193
201
  case "weekdays":
194
- return `${time}every weekday${range}`;
202
+ return `every weekday${at}${range}`;
195
203
  case "weekly": {
196
204
  const names = WEEK_DAYS.filter((d) => (t.daysOfWeek || []).includes(d.value)).map((d) => d.title);
197
- return `${time}every ${names.join(", ") || "week"}${range}`;
205
+ return `every ${names.join(", ") || "week"}${at}${range}`;
198
206
  }
199
207
  case "monthly": {
200
208
  const day = t.startDate ? Number(t.startDate.slice(8, 10)) : null;
201
209
  const on = day === null ? "every month" : day === 31 ? "on the last day of every month" : `on day ${day} of every month`;
202
- return `${time}${on}${range}`;
210
+ return `${on}${at}${range}`;
203
211
  }
204
212
  default: {
205
213
  const sorted = [...dates].sort();
206
- if (!sorted.length) return `${time}on no date`.trim();
214
+ if (!sorted.length) return `on no date${at}`;
207
215
  const shown = sorted.slice(0, 3).map((d) => fmtDate(d, nowYear)).join(", ");
208
- return `${time}on ${shown}${sorted.length > 3 ? ` +${sorted.length - 3} more` : ""}${
216
+ return `on ${shown}${sorted.length > 3 ? ` +${sorted.length - 3} more` : ""}${at}${
209
217
  t.endDate ? ` (until ${fmtDate(t.endDate, nowYear)})` : ""
210
218
  }`;
211
219
  }
@@ -226,33 +234,93 @@ export function buildSummary(
226
234
  return `Adds 1 extra visit to ${areas} (${units}) ${describeWhen(f, year)} — next: ${nextText}.`;
227
235
  }
228
236
 
229
- export const STATUS_LABELS: Record<string, string> = { active: "Active", paused: "Paused", ended: "Ended" };
237
+ /** The state of an extra visit as a whole. "ended" reads "Finished": nothing is left to run. */
238
+ export const STATUS_LABELS: Record<string, string> = { active: "Active", paused: "Paused", ended: "Finished" };
239
+
240
+ /** What each word means, shown on the page so nobody has to guess. Same words as the mobile apps. */
241
+ export const STATUS_HELP: { word: string; meaning: string }[] = [
242
+ { word: "Active", meaning: "It will add a visit at its next date and time." },
243
+ { word: "Paused", meaning: "Stopped for now. Nothing is added until you press Resume." },
244
+ { word: "Finished", meaning: "Its last date has passed, so nothing more is added. Press \"Extend or repeat\" to keep it going." },
245
+ ];
246
+ export const RESULT_HELP: { word: string; meaning: string }[] = [
247
+ { word: "Not started", meaning: "Nothing is ticked yet." },
248
+ { word: "In progress", meaning: "Some units are ticked; the rest can still be done today." },
249
+ { word: "Done", meaning: "Staff ticked every unit of the visit." },
250
+ { word: "Needs redo", meaning: "A supervisor sent a unit back to be done again." },
251
+ { word: "Missed", meaning: "The day ended with units not done." },
252
+ ];
230
253
 
231
- /** "Done 20 Oct" / "Missed 20 Oct" / "Not run yet". */
254
+ /** "Done 20 Oct" / "Missed 20 Oct" / "No visit yet". */
232
255
  export function lastRunLabel(row: { lastRun?: string | null; lastRunStatus?: string | null }): string {
233
- if (!row.lastRun) return "Not run yet";
256
+ if (!row.lastRun) return "No visit yet";
234
257
  const m = /^(\d{4}-\d{2}-\d{2})/.exec(row.lastRun);
235
258
  const day = m ? fmtDate(m[1]) : "";
236
- const word = { done: "Done", missed: "Missed", pending: "In progress" }[row.lastRunStatus || ""] || "Ran";
259
+ const word = { done: "Done", missed: "Missed", pending: "In progress" }[row.lastRunStatus || ""] || "Added";
237
260
  return `${word} ${day}`.trim();
238
261
  }
239
262
 
263
+ /** Why an extra visit says Finished, in one sentence. */
264
+ export function finishedReason(row: Partial<TRule>, nowYear?: string): string {
265
+ if (row.repeat && row.repeat !== "none" && row.endDate) {
266
+ return `Its end date (${fmtDate(row.endDate, nowYear)}) has passed.`;
267
+ }
268
+ return "All the dates it was set for have passed.";
269
+ }
270
+
240
271
  /**
241
- * One name and one page path per trade. The names are the web menu names (M&E
242
- * is "Technician Schedule" - the mobile app mirrors it); the path is the folder
243
- * the trade app serves its daily schedule from.
272
+ * One sentence per list row: "Every Mon, Wed at 2:00 pm in Lobby D — next on Wed 22 Oct".
273
+ * Paused and finished rows say so instead of a next date.
244
274
  */
245
- export const SCHEDULE_NAMES: Record<string, { title: string; route: string }> = {
246
- cleaning_services: { title: "Cleaning Schedule", route: "cleaning-schedule" },
247
- landscaping_services: { title: "Landscape Schedule", route: "landscape-schedule" },
248
- pest_control_services: { title: "Pest Schedule", route: "pest-schedule" },
249
- pool_maintenance_services: { title: "Pool Schedule", route: "pool-schedule" },
250
- mechanical_electrical_services: { title: "Technician Schedule", route: "technician-schedule" },
275
+ export function describeRow(
276
+ row: Partial<TRule> & {
277
+ areas?: { name?: string }[];
278
+ nextRun?: string | null;
279
+ scheduleStatus?: string | null;
280
+ paused?: boolean;
281
+ },
282
+ nowYear?: string,
283
+ ): string {
284
+ const when = describeWhen(row, nowYear);
285
+ const names = (row.areas || []).map((a) => a?.name).filter(Boolean) as string[];
286
+ const where = names.length
287
+ ? ` in ${names.slice(0, 3).join(", ")}${names.length > 3 ? ` +${names.length - 3} more` : ""}`
288
+ : "";
289
+ const state = row.scheduleStatus || (row.paused ? "paused" : "active");
290
+ const m = /^(\d{4}-\d{2}-\d{2})/.exec(row.nextRun || "");
291
+ const tail =
292
+ state === "paused"
293
+ ? " — paused, nothing is added until you resume it"
294
+ : state === "ended"
295
+ ? " — finished"
296
+ : m
297
+ ? ` — next on ${DAYS[weekdayOf(m[1])]} ${fmtDate(m[1], nowYear)}`
298
+ : "";
299
+ return `${when.charAt(0).toUpperCase()}${when.slice(1)}${where}${tail}`;
300
+ }
301
+
302
+ /**
303
+ * One trade word and one page path per trade. Every trade's menu says "Daily
304
+ * Checklist" (owner 21 Oct; the mobile apps use the same name); the page title
305
+ * adds the trade ("Daily Checklist – Cleaning"). The path is the folder the
306
+ * trade app serves its daily checklist from - unchanged, so links and
307
+ * permissions keep working.
308
+ */
309
+ export const SCHEDULE_NAMES: Record<string, { title: string; trade: string; route: string }> = {
310
+ cleaning_services: { title: "Daily Checklist", trade: "Cleaning", route: "cleaning-schedule" },
311
+ landscaping_services: { title: "Daily Checklist", trade: "Landscape", route: "landscape-schedule" },
312
+ pest_control_services: { title: "Daily Checklist", trade: "Pest", route: "pest-schedule" },
313
+ pool_maintenance_services: { title: "Daily Checklist", trade: "Pool", route: "pool-schedule" },
314
+ mechanical_electrical_services: { title: "Daily Checklist", trade: "Technician", route: "technician-schedule" },
251
315
  };
252
316
 
253
317
  export const scheduleNameFor = (serviceType?: string) =>
254
318
  SCHEDULE_NAMES[serviceType || ""] || SCHEDULE_NAMES.cleaning_services;
255
319
 
320
+ /** "Daily Checklist – Cleaning" / "– Technician" / ... */
321
+ export const dailyChecklistTitle = (serviceType?: string) =>
322
+ `Daily Checklist – ${scheduleNameFor(serviceType).trade}`;
323
+
256
324
  /** Singapore "today" as YYYY-MM-DD, for "is today's list there yet". */
257
325
  export const sgtToday = (now: Date = new Date()) => sgtParts(now).date;
258
326
 
@@ -265,13 +333,28 @@ export function hasListForToday(rows: { createdAt?: string | Date }[], now: Date
265
333
  });
266
334
  }
267
335
 
268
- /** One spelling for the in-flight state ("Ongoing" / "On-going" -> "In progress"); other words pass through. */
336
+ /**
337
+ * The daily checklist's words, the same on web and mobile: Open -> Not started,
338
+ * Ongoing / On-going -> In progress, Completed -> Done. Closed stays Closed (the
339
+ * day ended; anything not ticked counts as Missed). Other words pass through.
340
+ */
269
341
  export function dailyStatusLabel(status: string | null | undefined): string {
270
342
  const s = String(status ?? "").trim();
271
343
  if (!s) return "No status";
272
- return /^on-?going$/i.test(s) ? "In progress" : s;
344
+ if (/^on-?going$/i.test(s)) return "In progress";
345
+ if (/^open$/i.test(s)) return "Not started";
346
+ if (/^completed?$/i.test(s)) return "Done";
347
+ return s;
273
348
  }
274
349
 
350
+ /** What the daily checklist's words mean, shown on the page. */
351
+ export const DAILY_STATUS_HELP: { word: string; meaning: string }[] = [
352
+ { word: "Not started", meaning: "The day's list is open and nothing is ticked yet." },
353
+ { word: "In progress", meaning: "Staff have ticked some units; the rest can still be done." },
354
+ { word: "Done", meaning: "Every unit on the list was ticked." },
355
+ { word: "Closed", meaning: "The day ended at midnight. The list is view-only, and anything not ticked counts as Missed." },
356
+ ];
357
+
275
358
  /** A unit's result in the history view: Done, Needs redo (rejected by a supervisor) or Missed (never done). */
276
359
  export function historyUnitLabel(status: string | null | undefined): "Done" | "Needs redo" | "Missed" {
277
360
  const s = String(status ?? "").trim().toLowerCase();