@7365admin1/layer-common 4.76.0 → 4.78.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # @iservice365/layer-common
2
2
 
3
+ ## 4.78.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 7050e64: Robot detail: virtual walls, safe and slow zones and the taught route drawn on the robot map (with a line naming them); records card adds status history, map details (walls, zones, versions, floor map history, obstacle picture), changes made in the maker's app and read-only robot settings; the 0-jobs state says the maker reports no finished cleaning jobs. Mobile robot-rules copy now carries the records rows.
8
+
9
+ ## 4.77.0
10
+
11
+ ### Minor Changes
12
+
13
+ - e3682f3: Robot detail: an Ounitech robot shows its real floor map with its area layer, area names and live pin ("position approximate" until Ounitech confirms the map corner); Clean area picks the areas to clean; new records card: recent cleaning jobs, alert history, every robot and base-station reading, parts wear, timed switch on/off and lifts. The site mini-map shows cleaning robots on their own map.
14
+
3
15
  ## 4.76.0
4
16
 
5
17
  ### Minor Changes
@@ -1914,11 +1926,11 @@
1914
1926
  `utils/console-tier.ts` + `composables/useConsoleTier.ts` mirror the server's
1915
1927
  own rule from two endpoints the console already calls, unprojected:
1916
1928
 
1917
- GET /api/members/user/:user/app/admin the Seven365 staff membership
1918
- GET /api/roles/id/:role that membership's role document
1929
+ GET /api/members/user/:user/app/admin the Seven365 staff membership
1930
+ GET /api/roles/id/:role that membership's role document
1919
1931
 
1920
- owner = member.type === "admin" && role.type === "admin" && role.default === true
1921
- staff = member.type === "admin" && role.type === "admin"
1932
+ owner = member.type === "admin" && role.type === "admin" && role.default === true
1933
+ staff = member.type === "admin" && role.type === "admin"
1922
1934
 
1923
1935
  `role.default` is the marker because it is the only property of a platform
1924
1936
  staff role no API caller can set - `role.controller.ts` validates create and
@@ -670,6 +670,22 @@
670
670
  .hero-mapsel__hint { font-size: 12px; opacity: 0.75; }
671
671
  .hero-plan__pt { position: absolute; width: 8px; height: 8px; margin: -4px 0 0 -4px; border-radius: 50%; background: var(--hero-accent); pointer-events: none; }
672
672
  .hero-plan__ptname { position: absolute; transform: translate(6px, -50%); font-size: 11px; white-space: nowrap; pointer-events: none; color: var(--hero-text); }
673
+ .hero-plan__overlay { position: absolute; inset: 0; width: 100%; height: 100%; max-width: none; object-fit: fill; pointer-events: none; user-select: none; opacity: 0.35; }
674
+ .hero-plan__zones { position: absolute; inset: 0; width: 100%; height: 100%; pointer-events: none; overflow: visible; }
675
+ .hero-plan__zones polyline, .hero-plan__zones polygon { vector-effect: non-scaling-stroke; stroke-width: 2; stroke-linejoin: round; }
676
+ .hero-plan__wall { fill: none; stroke: #e53935; }
677
+ .hero-plan__safe { fill: rgba(67, 160, 71, 0.18); stroke: #43a047; }
678
+ .hero-plan__slow { fill: rgba(251, 140, 0, 0.18); stroke: #fb8c00; }
679
+ .hero-plan__taught { fill: none; stroke: #1e88e5; stroke-dasharray: 4 3; }
680
+ .hero-rec__obstacles { display: block; max-width: 100%; max-height: 320px; margin-top: 4px; border: 1px solid var(--hero-line, rgba(127, 127, 127, 0.3)); image-rendering: pixelated; }
681
+ .hero-plan__area { position: absolute; transform: translate(-50%, -50%); font-size: 11px; font-weight: 600; padding: 0 4px; white-space: nowrap; pointer-events: none; color: var(--hero-text); background: var(--hero-bg); border: 1px solid var(--hero-accent); }
682
+ .hero-rec { margin-top: 16px; }
683
+ .hero-rec__table { width: 100%; border-collapse: collapse; font-size: 13px; }
684
+ .hero-rec__table th { text-align: left; font-weight: 600; font-size: 12px; opacity: 0.75; padding: 4px 6px; border-bottom: 1px solid var(--hero-divider); }
685
+ .hero-rec__table td { padding: 4px 6px; border-bottom: 1px solid var(--hero-divider); vertical-align: top; }
686
+ .hero-rec__wrap { overflow-x: auto; }
687
+ .hero-rec__grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 2px 16px; }
688
+ .hero-rec__attn { color: var(--hero-accent); font-weight: 600; }
673
689
  .hero-plan:fullscreen { background-color: var(--hero-bg); }
674
690
  .hero-plan__corner { position: absolute; width: 14px; height: 14px; }
675
691
  .hero-plan__corner--tl { left: -1px; top: -1px; border-top: 2px solid var(--hero-accent); border-left: 2px solid var(--hero-accent); }
@@ -0,0 +1,210 @@
1
+ <template>
2
+ <!--
3
+ Robot detail's records (`/robots/:id/records`, read-only): recent cleaning
4
+ jobs, alert history, status history, then - folded - every reading the
5
+ robot sends, parts wear, the power schedule and lifts, map details (walls,
6
+ zones, versions, obstacles), changes made in the maker's app and the
7
+ robot's settings. A section shows only when the brand sends its part;
8
+ brands without records render nothing.
9
+ A part the maker refused is said in words, never left blank.
10
+ -->
11
+ <section v-if="records?.supported" class="hero-rec" aria-label="Cleaning records">
12
+ <div class="hero-aside__head">
13
+ <span class="hero-section__title">Recent cleaning jobs</span>
14
+ <span class="hero-aside__hint">last 14 days · from {{ makerApp }}</span>
15
+ <span class="hero-head__spacer" />
16
+ <v-btn variant="text" size="small" :loading="loading" @click="load(true)">Refresh</v-btn>
17
+ </div>
18
+ <div v-for="e in errors" :key="e" class="hero-notice hero-notice--error" role="status">{{ e }}</div>
19
+ <div v-if="!jobs.length && !records.tasks?.error" class="hero-sched__empty">
20
+ {{ noJobs }} {{ noJobsMore }}
21
+ </div>
22
+ <div v-else class="hero-rec__wrap">
23
+ <table class="hero-rec__table">
24
+ <thead>
25
+ <tr><th>Started</th><th>Area</th><th>Job</th><th>Took</th><th>Cleaned</th><th>Done</th><th>Result</th><th>Started by</th></tr>
26
+ </thead>
27
+ <tbody>
28
+ <tr v-for="j in shownJobs" :key="j.key">
29
+ <td>{{ j.when }}</td><td>{{ j.area }}</td><td>{{ j.job }}</td><td>{{ j.duration }}</td><td>{{ j.size }}</td><td>{{ j.done }}</td><td>{{ j.result }}</td><td>{{ j.by }}</td>
30
+ </tr>
31
+ </tbody>
32
+ </table>
33
+ <v-btn v-if="jobs.length > 5" variant="text" size="small" @click="allJobs = !allJobs">{{ allJobs ? "Show fewer" : `See all ${jobs.length}` }}</v-btn>
34
+ </div>
35
+
36
+ <div class="hero-section__title mt-4">Alert history</div>
37
+ <div class="hero-sched__note">What the robot reported in the last 30 days (open alerts are listed above).</div>
38
+ <div v-if="!alerts.length" class="hero-sched__empty">No alerts in the last 30 days.</div>
39
+ <div v-for="a in shownAlerts" :key="a.key" class="hero-sched__row hero-sched__row--alert" :title="a.vendorText">
40
+ <b class="hero-detail__alertcode">{{ a.code }}</b>
41
+ <span>{{ a.level ? `${a.level} · ` : "" }}{{ a.message }}</span>
42
+ <span>{{ a.when }} · {{ a.state }}</span>
43
+ </div>
44
+ <v-btn v-if="alerts.length > 5" variant="text" size="small" @click="allAlerts = !allAlerts">{{ allAlerts ? "Show fewer" : `See all ${alerts.length}` }}</v-btn>
45
+
46
+ <template v-if="has.has('statusLog')">
47
+ <div class="hero-section__title mt-4">Status history</div>
48
+ <div class="hero-sched__note">What the robot was doing in the last 7 days (cleaning, returning, charging and so on).</div>
49
+ <div v-if="!statusLog.length" class="hero-sched__empty">No status changes in the last 7 days.</div>
50
+ <div v-for="l in shownLog" :key="l.key" class="hero-sched__row"><b>{{ l.status }}</b><span>{{ l.when }}</span></div>
51
+ <v-btn v-if="statusLog.length > 5" variant="text" size="small" @click="allLog = !allLog">{{ allLog ? "Show fewer" : `See all ${statusLog.length}` }}</v-btn>
52
+ </template>
53
+
54
+ <details class="hero-can hero-can--fold" :open="attention.length > 0">
55
+ <summary class="hero-can__title">
56
+ Robot readings{{ attention.length ? ` · needs attention: ${attention.join(", ")}` : "" }}
57
+ </summary>
58
+ <div v-if="!groups.length" class="hero-sched__empty">The robot sent no readings.</div>
59
+ <div v-for="g in groups" :key="g.group" class="mb-2">
60
+ <div class="hero-sched__note">{{ g.group }}</div>
61
+ <div class="hero-rec__grid">
62
+ <div v-for="r in g.rows" :key="r.key" class="hero-can__row">
63
+ <span class="hero-can__label">{{ r.label }}</span>
64
+ <span :class="{ 'hero-rec__attn': r.attention }">{{ r.value }}</span>
65
+ </div>
66
+ </div>
67
+ </div>
68
+ </details>
69
+
70
+ <details class="hero-can hero-can--fold">
71
+ <summary class="hero-can__title">Parts wear, power schedule and lifts</summary>
72
+ <div class="hero-sched__note">Parts wear (hours worked since last reset in {{ makerApp }})</div>
73
+ <div v-if="!parts.length" class="hero-sched__empty">No parts reported.</div>
74
+ <div v-for="p in parts" :key="p.key" class="hero-sched__row"><b>{{ p.part }}</b><span>{{ p.worked }}</span><span>{{ p.days }}</span></div>
75
+ <div class="hero-sched__note mt-2">Timed switch on / off</div>
76
+ <div v-if="!power.length" class="hero-sched__empty">No timed switch on / off.</div>
77
+ <div v-for="p in power" :key="p.key" class="hero-sched__row">
78
+ <b>{{ p.days }}</b><span>{{ p.time }}</span><span>{{ p.action }}{{ p.enabled ? "" : " · off" }}</span>
79
+ </div>
80
+ <div class="hero-sched__note mt-2">Lifts linked in {{ makerApp }}</div>
81
+ <div v-if="!lifts.length" class="hero-sched__empty">No lifts linked to this robot's building.</div>
82
+ <div v-for="l in lifts" :key="l.key" class="hero-sched__row"><b>{{ l.name }}</b><span>{{ l.state }}</span></div>
83
+ </details>
84
+
85
+ <details v-if="has.has('mapVersions') || has.has('mapHistory') || has.has('mapLayers') || has.has('obstacles')" class="hero-can hero-can--fold">
86
+ <summary class="hero-can__title">Map details: walls, zones, versions and obstacles</summary>
87
+ <div class="hero-sched__note">Walls and zones on the robot's map (also drawn on the map above)</div>
88
+ <div v-if="!zones.length" class="hero-sched__empty">No virtual walls, safe zones or slow zones on this map.</div>
89
+ <div v-for="z in zones" :key="z.key" class="hero-sched__row"><b>{{ z.kind }}</b><span>{{ z.names }}</span></div>
90
+ <template v-if="has.has('mapVersions')">
91
+ <div class="hero-sched__note mt-2">Versions of this map (each re-scan or edit in {{ makerApp }})</div>
92
+ <div v-if="!versions.length" class="hero-sched__empty">No versions listed.</div>
93
+ <div v-for="v in versions" :key="v.key" class="hero-sched__row"><b>{{ v.version }}</b><span>{{ v.when }}</span><span>{{ v.state }}</span></div>
94
+ </template>
95
+ <template v-if="has.has('mapHistory')">
96
+ <div class="hero-sched__note mt-2">Every map made for this floor</div>
97
+ <div v-if="!history.length" class="hero-sched__empty">No maps listed for this floor.</div>
98
+ <div v-for="m in history" :key="m.key" class="hero-sched__row"><b>{{ m.name }}</b><span>{{ m.size }} · {{ m.when }}</span><span>{{ m.state }}</span></div>
99
+ </template>
100
+ <template v-if="has.has('obstacles')">
101
+ <div class="hero-sched__note mt-2">Obstacles the robot's sensors have marked (as {{ brand || "the maker" }} draws them)</div>
102
+ <img v-if="obstacles.image" class="hero-rec__obstacles" :src="obstacles.image" alt="Obstacles marked by the robot's sensors" />
103
+ <div v-else class="hero-sched__empty">{{ obstacles.note }}</div>
104
+ </template>
105
+ </details>
106
+
107
+ <details v-if="has.has('appActions')" class="hero-can hero-can--fold">
108
+ <summary class="hero-can__title">Changes made in {{ makerApp }} · last 30 days</summary>
109
+ <div v-if="!actions.length" class="hero-sched__empty">Nobody used {{ makerApp }} on this robot in the last 30 days.</div>
110
+ <div v-for="a in actions" :key="a.key" class="hero-sched__row"><b>{{ a.action }}</b><span>{{ a.when }}</span><span>{{ a.by }}</span></div>
111
+ </details>
112
+
113
+ <details v-if="has.has('settings')" class="hero-can hero-can--fold">
114
+ <summary class="hero-can__title">Robot settings (read-only)</summary>
115
+ <div class="hero-sched__note">As the robot reports them. Settings are changed in {{ makerApp }}.</div>
116
+ <div v-if="!settings.length" class="hero-sched__empty">The robot sent no settings.</div>
117
+ <div v-for="g in settings" :key="g.group" class="mb-2">
118
+ <div class="hero-sched__note">{{ g.group }}</div>
119
+ <div class="hero-rec__grid">
120
+ <div v-for="r in g.rows" :key="r.key" class="hero-can__row">
121
+ <span class="hero-can__label">{{ r.label }}</span>
122
+ <span>{{ r.value }}</span>
123
+ </div>
124
+ </div>
125
+ </div>
126
+ </details>
127
+ </section>
128
+ </template>
129
+
130
+ <script setup lang="ts">
131
+ import { computed, ref, watch } from "vue";
132
+ import { robotErrorMessage } from "../../utils/robot-display";
133
+ import {
134
+ alertHistoryRows,
135
+ appActionRows,
136
+ attentionReadings,
137
+ jobRows,
138
+ liftRows,
139
+ mapHistoryRows,
140
+ mapVersionRows,
141
+ noJobsHint,
142
+ noJobsText,
143
+ obstaclePicture,
144
+ partRows,
145
+ powerRows,
146
+ readingGroups,
147
+ recordErrors,
148
+ recordParts,
149
+ settingGroups,
150
+ statusLogRows,
151
+ zoneRows,
152
+ } from "../../utils/robot-records";
153
+
154
+ const props = defineProps<{ robotId: string; makerApp: string; brand?: string }>();
155
+ const { getRobotRecords } = useRobots();
156
+
157
+ const records = ref<Record<string, any> | null>(null);
158
+ const loading = ref(false);
159
+ const allJobs = ref(false);
160
+ const allAlerts = ref(false);
161
+ const allLog = ref(false);
162
+
163
+ // ponytail: read on open and on Refresh only - the maker rate-limits these reads (the API caches 2 min).
164
+ async function load(force = false) {
165
+ if (loading.value && !force) return;
166
+ loading.value = true;
167
+ try {
168
+ records.value = await getRobotRecords(props.robotId);
169
+ } catch (e: any) {
170
+ // An API without the records route (404) shows no card; any other failure is said.
171
+ const status = e?.statusCode ?? e?.status ?? e?.response?.status;
172
+ records.value = status === 404 ? null : { supported: true, error: `Couldn't read the records: ${robotErrorMessage(e)}` };
173
+ } finally {
174
+ loading.value = false;
175
+ }
176
+ }
177
+ watch(
178
+ () => props.robotId,
179
+ () => {
180
+ records.value = null;
181
+ allJobs.value = false;
182
+ allAlerts.value = false;
183
+ allLog.value = false;
184
+ load(true);
185
+ },
186
+ { immediate: true },
187
+ );
188
+
189
+ const jobs = computed(() => jobRows(records.value));
190
+ const shownJobs = computed(() => (allJobs.value ? jobs.value : jobs.value.slice(0, 5)));
191
+ const alerts = computed(() => alertHistoryRows(records.value));
192
+ const shownAlerts = computed(() => (allAlerts.value ? alerts.value : alerts.value.slice(0, 5)));
193
+ const groups = computed(() => readingGroups(records.value));
194
+ const attention = computed(() => attentionReadings(records.value));
195
+ const parts = computed(() => partRows(records.value));
196
+ const power = computed(() => powerRows(records.value));
197
+ const lifts = computed(() => liftRows(records.value));
198
+ const errors = computed(() => recordErrors(records.value));
199
+ const noJobs = computed(() => noJobsText(props.brand ?? ""));
200
+ const noJobsMore = computed(() => noJobsHint(props.brand ?? ""));
201
+ const has = computed(() => recordParts(records.value));
202
+ const statusLog = computed(() => statusLogRows(records.value));
203
+ const shownLog = computed(() => (allLog.value ? statusLog.value : statusLog.value.slice(0, 5)));
204
+ const actions = computed(() => appActionRows(records.value));
205
+ const settings = computed(() => settingGroups(records.value));
206
+ const zones = computed(() => zoneRows(records.value));
207
+ const versions = computed(() => mapVersionRows(records.value));
208
+ const history = computed(() => mapHistoryRows(records.value));
209
+ const obstacles = computed(() => obstaclePicture(records.value));
210
+ </script>
@@ -111,12 +111,20 @@
111
111
  :image="selectedMap?.imageUrl ?? null"
112
112
  :aspect="selectedMap?.width && selectedMap?.height ? selectedMap.width / selectedMap.height : null"
113
113
  :points="selectedMap?.points ?? []"
114
+ :overlay="selectedMap?.overlay ?? null"
115
+ :areas="selectedMap?.areas ?? []"
116
+ :zones="selectedMap?.zones ?? []"
117
+ :taught-route="selectedMap?.taughtRoute ?? []"
114
118
  :route-moving="statusMode === 'WORKING' && robotOnShownMap"
115
119
  :spot="spot"
116
120
  :pick="spotOn"
117
121
  :tick-ms="tickMs"
118
122
  @pick="(p) => spot.push(p)"
119
123
  />
124
+ <p v-if="mapLegend" class="hero-muted hero-detail__lag">{{ mapLegend }}</p>
125
+ <p v-if="selectedMap?.approximate" class="hero-muted hero-detail__lag">
126
+ Robot position is approximate: {{ brand }} has not yet confirmed which corner their map is measured from.
127
+ </p>
120
128
  <div v-if="spotOn" class="hero-spotbar">
121
129
  <v-btn color="primary" variant="flat" :disabled="!spot.length || busy === 'spot'" :loading="busy === 'spot'" @click="sendSpot">
122
130
  Send spot clean · {{ spot.length }} points
@@ -187,6 +195,9 @@
187
195
  </div>
188
196
  </section>
189
197
 
198
+ <!-- Cleaning robots: recent jobs, alert history, every reading, parts wear, power schedule, lifts (read-only). -->
199
+ <RobotCleaningRecords v-if="!noInterface && record && robotType !== 'security'" :robot-id="robotId" :maker-app="makerApp" :brand="brand" />
200
+
190
201
  <!-- What THIS robot (brand + model) can do, in our words; folded so it never pushes the camera down. -->
191
202
  <details v-if="abilities.length" class="hero-can hero-can--fold">
192
203
  <summary class="hero-can__title">What this robot can do</summary>
@@ -199,6 +210,19 @@
199
210
  <v-dialog v-model="confirmOpen" width="440">
200
211
  <div v-if="confirming" class="hero-dialog" role="alertdialog" aria-labelledby="hero-confirm-title">
201
212
  <div id="hero-confirm-title" class="hero-dialog__title">{{ confirming.text.title }}</div>
213
+ <v-select
214
+ v-if="confirming.zones?.length"
215
+ v-model="confirming.zoneNames"
216
+ :items="confirming.zones"
217
+ label="Areas to clean"
218
+ multiple
219
+ chips
220
+ closable-chips
221
+ density="compact"
222
+ hide-details
223
+ class="mb-2"
224
+ @update:model-value="pickZones"
225
+ />
202
226
  <v-select
203
227
  v-if="confirming.routes?.length"
204
228
  v-model="confirming.routeId"
@@ -214,7 +238,7 @@
214
238
  <div class="hero-dialog__body">{{ confirming.text.body }}</div>
215
239
  <div class="hero-dialog__actions">
216
240
  <v-btn variant="outlined" @click="confirmOpen = false">Don't send</v-btn>
217
- <v-btn color="primary" variant="flat" @click="confirmSend">{{ confirming.text.action }}</v-btn>
241
+ <v-btn color="primary" variant="flat" :disabled="!!confirming.zones && !confirming.zoneNames?.length" @click="confirmSend">{{ confirming.text.action }}</v-btn>
218
242
  </div>
219
243
  </div>
220
244
  </v-dialog>
@@ -272,6 +296,7 @@ import RobotNoMonitoring from "./NoMonitoring.vue";
272
296
  import RobotCommandBar from "./CommandBar.vue";
273
297
  import RobotViewOnlyNotice from "./ViewOnlyNotice.vue";
274
298
  import RobotLivePlan from "./LivePlan.vue";
299
+ import RobotCleaningRecords from "./CleaningRecords.vue";
275
300
  import RobotToast from "./Toast.vue";
276
301
  import RobotForm from "./Form.vue";
277
302
  import RobotPatrolHistory from "./PatrolHistory.vue";
@@ -279,7 +304,7 @@ import RobotLiveView from "./LiveView.vue";
279
304
  import RobotManualDrive from "./ManualDrive.vue";
280
305
  import RobotPointPicker from "./PointPicker.vue";
281
306
  import { faultPhotos } from "../../utils/robot-control";
282
- import { robotMapDefault, robotMapViews } from "../../utils/robot-maps";
307
+ import { cleanAreaChoices, cleanAreaCommand, mapLayerLegend, robotMapDefault, robotMapViews } from "../../utils/robot-maps";
283
308
  import useRobotLive, { siteStatesFeed } from "../../composables/useRobotLive";
284
309
  import {
285
310
  attentionText,
@@ -495,6 +520,10 @@ const planTitle = computed(() => {
495
520
  const planHint = computed(() => {
496
521
  if (spotOn.value) return "Click points on the plan, then send";
497
522
  if (noInterface.value) return "No position feed for this model";
523
+ if (selectedMap.value && robotType.value !== "security") {
524
+ const n = selectedMap.value.areas.length;
525
+ return `${n} cleaning area${n === 1 ? "" : "s"}`;
526
+ }
498
527
  if (selectedMap.value) return `${selectedMap.value.points.length} saved points · ${mapRoutes.value.length} routes`;
499
528
  if (route.value) return `${route.value.checkpoints.length} checkpoints · ${floorText.value}`;
500
529
  return ""; // the Floor cell above already says it
@@ -548,6 +577,7 @@ const mapItems = ref<Array<Record<string, any>>>([]);
548
577
  const mapViews = computed(() => robotMapViews(mapItems.value, live.value?.mapId ?? null, patrol.value?.routes ?? []));
549
578
  const selectedMapId = ref<string | null>(null);
550
579
  const selectedMap = computed(() => mapViews.value.find((m) => m.mapId === selectedMapId.value) ?? null);
580
+ const mapLegend = computed(() => mapLayerLegend(selectedMap.value));
551
581
  watch(mapViews, (views) => {
552
582
  if (!views.some((m) => m.mapId === selectedMapId.value)) selectedMapId.value = robotMapDefault(mapItems.value, views);
553
583
  });
@@ -689,6 +719,18 @@ async function send(kind: TRobotCommandKind) {
689
719
  return;
690
720
  }
691
721
  }
722
+ // Clean area (cleaning robots): the API cleans named areas, so pick them from the robot's map.
723
+ if (kind === "area" && robotType.value !== "security") {
724
+ const { areas: zones, empty } = cleanAreaChoices(mapViews.value, selectedMapId.value, makerApp.value);
725
+ if (!zones.length) {
726
+ commandResult.value = { text: empty, warn: true };
727
+ return;
728
+ }
729
+ confirming.value = { kind: "area", body: {}, text: { title: "", body: "", action: "" }, zones, zoneNames: [zones[0]!] };
730
+ pickZones([zones[0]!]);
731
+ confirmOpen.value = true;
732
+ return;
733
+ }
692
734
  const body = commandBody(kind, live.value);
693
735
  if (!body) {
694
736
  toast.value = `${commandLabel(kind)}: the robot's position is not known yet.`;
@@ -705,8 +747,21 @@ const confirming = ref<{
705
747
  text: { title: string; body: string; action: string };
706
748
  routes?: TPatrolRouteView[];
707
749
  routeId?: string;
750
+ /** Clean area: the map's area names, and the ones picked. */
751
+ zones?: string[];
752
+ zoneNames?: string[];
708
753
  what?: string;
709
754
  } | null>(null);
755
+ /** The areas picked in Clean area's confirm: the body names them and the sentence says them. */
756
+ function pickZones(names: unknown) {
757
+ const c = confirming.value;
758
+ if (!c?.zones) return;
759
+ const cmd = cleanAreaCommand(record.value?.name, c.zones, names);
760
+ c.zoneNames = cmd.body.areas;
761
+ c.body = cmd.body;
762
+ c.what = cmd.what;
763
+ c.text = { ...commandConfirm("area", { type: robotType.value, name: record.value?.name }), body: cmd.sentence };
764
+ }
710
765
  /** The route picked in Start patrol's confirm: its exact words, and the body naming it. */
711
766
  function pickRoute(id: unknown) {
712
767
  const c = confirming.value;
@@ -9,6 +9,15 @@
9
9
  is lost - never a frozen dot.
10
10
  -->
11
11
  <RobotFloorPlan detail :pick="pick" :image="image" :aspect="aspect" @pick="emit('pick', $event)">
12
+ <img v-if="image && overlay" class="hero-plan__overlay" :src="overlay" alt="" aria-hidden="true" draggable="false" referrerpolicy="no-referrer" />
13
+ <svg v-if="image && (zones.length || taughtRoute.length > 1)" class="hero-plan__zones" viewBox="0 0 100 100" preserveAspectRatio="none" aria-hidden="true">
14
+ <template v-for="(z, i) in zones" :key="`${z.kind}-${i}`">
15
+ <polyline v-if="z.kind === 'wall'" class="hero-plan__wall" :points="svgPoints(z.points)"><title>{{ z.name }}</title></polyline>
16
+ <polygon v-else :class="z.kind === 'safe' ? 'hero-plan__safe' : 'hero-plan__slow'" :points="svgPoints(z.points)"><title>{{ z.name }}</title></polygon>
17
+ </template>
18
+ <polyline v-if="taughtRoute.length > 1" class="hero-plan__taught" :points="svgPoints(taughtRoute)" />
19
+ </svg>
20
+ <span v-for="a in placedAreas" :key="a.name" class="hero-plan__area" :style="{ left: `${a.at!.x}%`, top: `${a.at!.y}%` }">{{ a.name }}</span>
12
21
  <template v-for="p in points" :key="p.targetPointId">
13
22
  <span class="hero-plan__pt" :style="{ left: `${p.x}%`, top: `${p.y}%` }" aria-hidden="true" />
14
23
  <span class="hero-plan__ptname" :style="{ left: `${p.x}%`, top: `${p.y}%` }">{{ p.name }}</span>
@@ -56,6 +65,12 @@ const props = withDefaults(
56
65
  /** The map shown: the robot's own image and its width / height. */
57
66
  image?: string | null;
58
67
  aspect?: number | null;
68
+ /** The maker's coloured area layer over the map image (Ounitech), and its areas' names + label spots. */
69
+ overlay?: string | null;
70
+ areas?: Array<{ name: string; at: { x: number; y: number } | null }>;
71
+ /** Virtual walls / safe zones / slow zones and the taught route on the map shown (0-100 shares). */
72
+ zones?: Array<{ kind: "wall" | "safe" | "slow"; name: string; points: Array<{ x: number; y: number }> }>;
73
+ taughtRoute?: Array<{ x: number; y: number }>;
59
74
  /** Saved points on the map shown (0-100 shares). */
60
75
  points?: Array<{ targetPointId: string; name: string; x: number; y: number }>;
61
76
  /** The route's dash walks only while the robot patrols. */
@@ -67,8 +82,10 @@ const props = withDefaults(
67
82
  /** The last reading kept through a short gap: drawn faded, never as live. */
68
83
  stale?: boolean;
69
84
  }>(),
70
- { stale: false, name: "", statusLabel: "", point: null, heading: 0, trail: () => [], readout: "", lost: false, route: null, routes: null, image: null, aspect: null, points: () => [], routeMoving: false, spot: () => [], pick: false, tickMs: 1200 },
85
+ { stale: false, name: "", statusLabel: "", point: null, heading: 0, trail: () => [], readout: "", lost: false, route: null, routes: null, image: null, aspect: null, overlay: null, areas: () => [], zones: () => [], taughtRoute: () => [], points: () => [], routeMoving: false, spot: () => [], pick: false, tickMs: 1200 },
71
86
  );
87
+ const svgPoints = (pts: Array<{ x: number; y: number }>) => pts.map((p) => `${p.x},${p.y}`).join(" ");
88
+ const placedAreas = computed(() => (props.image ? props.areas.filter((a) => a.at) : []));
72
89
  const emit = defineEmits<{ pick: [point: { x: number; y: number }] }>();
73
90
  // The tag stays inside the plan (flipped left near the right edge, below near the top) - never clipped.
74
91
  const tagClass = computed(() => {
@@ -246,19 +246,20 @@ function seenText(row: any) {
246
246
 
247
247
  /* ---- patrol routes for the security robots on screen ---- */
248
248
  const routes = ref<Record<string, TPatrolRouteView[]>>({});
249
- /** Each security robot's own maps (PadBot map images), read once per robot. */
249
+ /** Each robot's own maps (PadBot / Ounitech map images), read once per robot. */
250
+ // ponytail: read once; the maker signs image URLs for ~1 h, so a page left open longer needs a reload.
250
251
  const robotMaps = ref<Record<string, Array<Record<string, any>>>>({});
251
252
  async function loadRoutes() {
252
- if (props.type !== "security") {
253
- routes.value = {};
254
- return;
255
- }
256
253
  const missing = ids.value.filter((id) => !(id in robotMaps.value));
257
254
  if (missing.length) {
258
255
  const got: Record<string, Array<Record<string, any>>> = {};
259
256
  await Promise.allSettled(missing.map(async (id) => (got[id] = (await getRobotMaps(id))?.items ?? [])));
260
257
  robotMaps.value = { ...robotMaps.value, ...Object.fromEntries(missing.map((id) => [id, got[id] ?? []])) };
261
258
  }
259
+ if (props.type !== "security") {
260
+ routes.value = {};
261
+ return;
262
+ }
262
263
  const next: Record<string, TPatrolRouteView[]> = {};
263
264
  await Promise.allSettled(
264
265
  ids.value.map(async (id) => {
@@ -150,6 +150,11 @@ export default function useRobots() {
150
150
  );
151
151
  }
152
152
 
153
+ /** Cleaning jobs, alert history, consumables, power schedule, lifts and every reading (read-only; `supported: false` for brands without). */
154
+ function getRobotRecords(id: string) {
155
+ return useNuxtApp().$api<Record<string, any>>(`/api/robots/${id}/records`, { method: "GET", query: { appSlug } });
156
+ }
157
+
153
158
  /**
154
159
  * Signed image URLs for a map's layers.
155
160
  *
@@ -592,6 +597,7 @@ export default function useRobots() {
592
597
  getRobotState,
593
598
  getRobotMaps,
594
599
  getRobotMapImages,
600
+ getRobotRecords,
595
601
  getRobotPlans,
596
602
  sendRobotCommand,
597
603
  getLiveVideo,
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.76.0",
5
+ "version": "4.78.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.",
@@ -1692,7 +1692,8 @@ export function robotAbilities(input: {
1692
1692
  on("floorReporting") && "floor",
1693
1693
  on("readLocation") && "live position",
1694
1694
  on("thermalCamera") && "heat readings",
1695
- on("waterTanks") && "water levels and tanks",
1695
+ on("waterTanks") && "water levels",
1696
+ on("waterTanks") && "tank checks",
1696
1697
  on("patrolHistory") && (on("thermalCamera") ? "past patrols with photos and temperatures" : "past patrols with photos"),
1697
1698
  ].filter(Boolean) as string[];
1698
1699
  const p = input?.profile ?? {};
@@ -22,8 +22,42 @@ export type TRobotMapView = {
22
22
  routeNames: string[];
23
23
  /** Saved points placed on this map's image (0-100 from its top-left). */
24
24
  points: TRobotMapPoint[];
25
+ /** The maker's coloured area layer (Ounitech partition image), drawn over the map; null when none. */
26
+ overlay: string | null;
27
+ /** The map's cleaning areas by name (Clean area picks from these); `at` = label spot, null when unknown. */
28
+ areas: Array<{ name: string; at: { x: number; y: number } | null }>;
29
+ /** The maker has not confirmed the map's corner, so the robot pin may be off: say "position approximate". */
30
+ approximate: boolean;
31
+ /** Virtual walls (never crossed), safe zones and slow zones on this map, as 0-100 shares. */
32
+ zones: TRobotMapZone[];
33
+ /** The route the robot was taught on this map (0-100 shares); empty when none. */
34
+ taughtRoute: Array<{ x: number; y: number }>;
25
35
  };
26
36
 
37
+ export type TRobotMapZone = { kind: "wall" | "safe" | "slow"; name: string; points: Array<{ x: number; y: number }> };
38
+
39
+ const share = (p: any) => isNum(p?.x) && isNum(p?.y) && p.x >= 0 && p.x <= 100 && p.y >= 0 && p.y <= 100;
40
+ function mapZones(list: unknown): TRobotMapZone[] {
41
+ return (Array.isArray(list) ? list : []).flatMap((z: any) => {
42
+ const points = (Array.isArray(z?.points) ? z.points : []).filter(share).map((p: any) => ({ x: p.x, y: p.y }));
43
+ return (z?.kind === "wall" || z?.kind === "safe" || z?.kind === "slow") && points.length >= 2 ? [{ kind: z.kind, name: String(z.name ?? ""), points }] : [];
44
+ });
45
+ }
46
+
47
+ /** One line under the map naming what is drawn on it: "Drawn on the map: 1 virtual wall (red) · 1 slow zone (amber) · taught route (blue dashes)". */
48
+ export function mapLayerLegend(view: Pick<TRobotMapView, "zones" | "taughtRoute"> | null | undefined): string {
49
+ if (!view) return "";
50
+ const n = (k: TRobotMapZone["kind"]) => view.zones.filter((z) => z.kind === k).length;
51
+ const part = (count: number, one: string, many: string, colour: string) => (count ? `${count} ${count === 1 ? one : many} (${colour})` : "");
52
+ const parts = [
53
+ part(n("wall"), "virtual wall", "virtual walls", "red"),
54
+ part(n("safe"), "safe zone", "safe zones", "green"),
55
+ part(n("slow"), "slow zone", "slow zones", "amber"),
56
+ view.taughtRoute.length >= 2 ? "taught route (blue dashes)" : "",
57
+ ].filter(Boolean);
58
+ return parts.length ? `Drawn on the map: ${parts.join(" · ")}` : "";
59
+ }
60
+
27
61
  const isNum = (v: unknown): v is number => typeof v === "number" && Number.isFinite(v);
28
62
  const levelName = (n: number) => (n > 0 ? `L${n}` : n < 0 ? `B${-n}` : "G");
29
63
  const TYPE_WORDS: Readonly<Record<number, string>> = { 0: "Point", 3: "Lift", 4: "Loading", 6: "Access door", 10: "Charging" };
@@ -47,7 +81,8 @@ export function robotMapShare(map: Record<string, any> | null | undefined, wx: u
47
81
  * `/robots/:id/maps` items (PadBot: `mapId` + `imageUrl`) as selector entries,
48
82
  * in PadBot's order. Named by PadBot's display name; without one, "Map N"
49
83
  * with its floor, else its first route's name, as the hint ("Map 1 (B1)").
50
- * [] for brands whose maps are not vendor images (Ounitech keeps its own view).
84
+ * Ounitech maps arrive in the same shape (the API adds mapId / imageUrl / size),
85
+ * plus their area layer, area names and "position approximate".
51
86
  */
52
87
  export function robotMapViews(
53
88
  items: Array<Record<string, any>> | null | undefined,
@@ -78,6 +113,13 @@ export function robotMapViews(
78
113
  current: !!currentMapId && m.mapId === currentMapId,
79
114
  routeNames,
80
115
  points,
116
+ overlay: typeof m.partitionImageUrl === "string" && /^https?:\/\//i.test(m.partitionImageUrl) ? m.partitionImageUrl : null,
117
+ areas: (Array.isArray(m.areas) ? m.areas : [])
118
+ .filter((a: any) => typeof a?.name === "string" && a.name.trim())
119
+ .map((a: any) => ({ name: String(a.name).trim(), at: a.at && isNum(a.at.x) && isNum(a.at.y) ? { x: a.at.x, y: a.at.y } : null })),
120
+ approximate: m.positionApproximate === true,
121
+ zones: mapZones(m.zones),
122
+ taughtRoute: (Array.isArray(m.taughtRoute) ? m.taughtRoute : []).filter(share).map((p: any) => ({ x: p.x, y: p.y })),
81
123
  };
82
124
  });
83
125
  }
@@ -107,3 +149,28 @@ export function robotMapsByFloor(views: TRobotMapView[]): Map<number, TRobotMapV
107
149
  for (const v of views) if (v.floorNo !== null) out.set(v.floorNo, [...(out.get(v.floorNo) ?? []), v]);
108
150
  return out;
109
151
  }
152
+
153
+ /**
154
+ * Clean area (cleaning robots, web and phones): the area names on the map the
155
+ * robot is on (else the map shown). `empty` is the sentence when there are none.
156
+ */
157
+ export function cleanAreaChoices(views: TRobotMapView[], selectedMapId: string | null | undefined, makerApp: string): { mapLabel: string; areas: string[]; empty: string } {
158
+ const map = views.find((v) => v.current) ?? views.find((v) => v.mapId === selectedMapId) ?? null;
159
+ const areas = map ? map.areas.map((a) => a.name) : [];
160
+ return {
161
+ mapLabel: map?.label ?? "",
162
+ areas,
163
+ empty: areas.length ? "" : `There are no cleaning areas on ${map ? map.label : "this robot's map"} yet. Areas are drawn in ${makerApp || "the maker's app"}.`,
164
+ };
165
+ }
166
+
167
+ /** The picked areas as the command body, the outcome's words and the confirm sentence (only names that are on the map). */
168
+ export function cleanAreaCommand(robotName: string | null | undefined, areas: string[], picked: unknown): { body: { kind: "area"; areas: string[] }; what: string | undefined; sentence: string } {
169
+ const names = (Array.isArray(picked) ? picked : []).filter((n): n is string => typeof n === "string" && areas.includes(n));
170
+ const list = names.length > 2 ? `${names.slice(0, -1).join(", ")} and ${names[names.length - 1]}` : names.join(" and ");
171
+ return {
172
+ body: { kind: "area", areas: names },
173
+ what: names.length ? `cleaning ${list}` : undefined,
174
+ sentence: names.length ? `${robotName || "The robot"} starts cleaning ${list}, one pass.` : "Pick at least one area.",
175
+ };
176
+ }
@@ -0,0 +1,213 @@
1
+ /**
2
+ * Robot detail's records card (`/robots/:id/records`): cleaning jobs, alert
3
+ * history, parts wear, power schedule, lifts and every reading the robot
4
+ * sends, as display rows. Pure helpers - no Vue, no Node globals.
5
+ */
6
+ import { dayRange } from "./robot-display.ts";
7
+
8
+ type TPart<T> = { items?: T[]; error?: string } | null | undefined;
9
+ const isNum = (v: unknown): v is number => typeof v === "number" && Number.isFinite(v);
10
+ const items = <T>(p: TPart<T>): T[] => (Array.isArray(p?.items) ? p!.items! : []);
11
+
12
+ /** "45 s", "16 min", "1 h 5 min"; "—" when unknown. */
13
+ export function durationText(sec: unknown): string {
14
+ if (!isNum(sec) || sec < 0) return "—";
15
+ if (sec < 60) return `${Math.round(sec)} s`;
16
+ const min = Math.round(sec / 60);
17
+ return min < 60 ? `${min} min` : `${Math.floor(min / 60)} h${min % 60 ? ` ${min % 60} min` : ""}`;
18
+ }
19
+
20
+ const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
21
+
22
+ /** An ISO time as "30 Sep, 08:15" in the viewer's zone (or `timeZone`); "—" when unknown. */
23
+ export function whenText(iso: unknown, timeZone?: string): string {
24
+ const t = typeof iso === "string" ? Date.parse(iso) : NaN;
25
+ if (!Number.isFinite(t)) return "—";
26
+ const parts = Object.fromEntries(
27
+ // Fixed "30 Sep, 08:15" parts (en-GB), never the device locale; the zone is the viewer's unless one is given.
28
+ new Intl.DateTimeFormat("en-GB", { timeZone: timeZone || undefined, day: "numeric", month: "numeric", hour: "2-digit", minute: "2-digit", hourCycle: "h23" })
29
+ .formatToParts(t)
30
+ .map((p) => [p.type, p.value]),
31
+ );
32
+ return `${parts.day} ${MONTHS[Number(parts.month) - 1]}, ${parts.hour}:${parts.minute}`;
33
+ }
34
+
35
+ export type TJobRow = { key: string; when: string; area: string; job: string; duration: string; size: string; done: string; result: string; by: string };
36
+
37
+ /** Cleaning jobs, newest first as the API sends them. */
38
+ export function jobRows(records: { tasks?: TPart<Record<string, any>> } | null | undefined, timeZone?: string): TJobRow[] {
39
+ return items(records?.tasks).map((t, i) => {
40
+ const floor = isNum(t.floorNo) ? (t.floorNo > 0 ? `L${t.floorNo}` : t.floorNo < 0 ? `B${-t.floorNo}` : "G") : "";
41
+ const area = [t.map, floor].filter(Boolean).join(" · ") || "—";
42
+ const size = isNum(t.areaM2) ? (isNum(t.plannedM2) && t.plannedM2 > 0 ? `${t.areaM2} of ${t.plannedM2} m²` : `${t.areaM2} m²`) : "—";
43
+ return {
44
+ key: `${t.id ?? ""}-${i}`,
45
+ when: whenText(t.start, timeZone),
46
+ area,
47
+ job: [t.job, t.mode].filter(Boolean).join(" · ") || "—",
48
+ duration: durationText(t.durationSec),
49
+ size,
50
+ done: isNum(t.percent) ? `${Math.round(t.percent)}%` : "—",
51
+ result: t.result || "—",
52
+ by: t.source || "—",
53
+ };
54
+ });
55
+ }
56
+
57
+ export type TAlertRow = { key: string; when: string; code: string; level: string; message: string; state: string; vendorText: string };
58
+
59
+ export function alertHistoryRows(records: { alerts?: TPart<Record<string, any>> } | null | undefined, timeZone?: string): TAlertRow[] {
60
+ return items(records?.alerts).map((a, i) => ({
61
+ key: `${a.code ?? ""}-${a.at ?? ""}-${i}`,
62
+ when: whenText(a.at, timeZone),
63
+ code: a.code ? String(a.code) : "—",
64
+ level: a.level || "",
65
+ message: a.message || a.vendorText || "—",
66
+ state: a.resolved ? "Resolved" : "Reported",
67
+ vendorText: a.vendorText && a.vendorText !== a.message ? String(a.vendorText) : "",
68
+ }));
69
+ }
70
+
71
+ /** Readings grouped in the API's order ("Battery", "Water", ...), base station last. */
72
+ export function readingGroups(records: { readings?: TPart<Record<string, any>>; station?: TPart<Record<string, any>> } | null | undefined) {
73
+ const rows = [...items(records?.readings), ...items(records?.station).flatMap((s) => (Array.isArray(s?.readings) ? s.readings : []))];
74
+ const status = items(records?.station)[0]?.status;
75
+ if (status) rows.push({ key: "station-status", group: "Base station", label: "Station status", value: String(status), attention: false });
76
+ const groups: Array<{ group: string; rows: Array<{ key: string; label: string; value: string; attention: boolean }> }> = [];
77
+ for (const r of rows) {
78
+ const g = String(r?.group || "Other readings");
79
+ let hit = groups.find((x) => x.group === g);
80
+ if (!hit) groups.push((hit = { group: g, rows: [] }));
81
+ hit.rows.push({ key: String(r.key ?? r.label), label: String(r.label ?? r.key), value: String(r.value ?? ""), attention: r.attention === true });
82
+ }
83
+ return groups;
84
+ }
85
+
86
+ /** Readings that need a person ("Dust box fitted: No"), for the card's one-line summary. */
87
+ export function attentionReadings(records: Parameters<typeof readingGroups>[0]): string[] {
88
+ return readingGroups(records).flatMap((g) => g.rows.filter((r) => r.attention).map((r) => `${r.label}: ${r.value}`));
89
+ }
90
+
91
+ export function partRows(records: { consumables?: TPart<Record<string, any>> } | null | undefined) {
92
+ return items(records?.consumables).map((c, i) => ({
93
+ key: `${c.part}-${i}`,
94
+ part: String(c.part ?? "Part"),
95
+ worked: isNum(c.hours) ? `${c.hours} h` : "—",
96
+ days: isNum(c.days) ? `${c.days} days` : "—",
97
+ }));
98
+ }
99
+
100
+ export function powerRows(records: { power?: TPart<Record<string, any>> } | null | undefined) {
101
+ return items(records?.power).map((p, i) => ({
102
+ key: `${p.time}-${i}`,
103
+ days: dayRange(p.days),
104
+ time: p.time || "—",
105
+ action: p.action === "on" ? "Switch on" : "Switch off",
106
+ enabled: p.enabled !== false,
107
+ }));
108
+ }
109
+
110
+ export function liftRows(records: { lifts?: TPart<Record<string, any>> } | null | undefined) {
111
+ return items(records?.lifts).map((l, i) => ({
112
+ key: `${l.name}-${i}`,
113
+ name: String(l.name ?? "Lift"),
114
+ state: `${l.online ? "Online" : "Offline"}${l.enabled === false ? " · disabled" : ""}${isNum(l.floorNo) ? ` · at L${l.floorNo}` : ""}`,
115
+ }));
116
+ }
117
+
118
+ /** "Couldn't read the cleaning records: …" per refused part, so a gap is said, never silent. */
119
+ export function recordErrors(records: Record<string, any> | null | undefined): string[] {
120
+ const names: Record<string, string> = {
121
+ tasks: "cleaning jobs",
122
+ alerts: "alert history",
123
+ consumables: "parts wear",
124
+ power: "power schedule",
125
+ lifts: "lifts",
126
+ readings: "robot readings",
127
+ station: "base station",
128
+ statusLog: "status history",
129
+ appActions: "changes made in the maker's app",
130
+ settings: "robot settings",
131
+ obstacles: "obstacle picture",
132
+ mapVersions: "map versions",
133
+ mapHistory: "floor's map history",
134
+ };
135
+ if (!records) return [];
136
+ const out = typeof records.error === "string" && records.error ? [records.error] : [];
137
+ for (const [k, name] of Object.entries(names)) {
138
+ const e = records[k]?.error;
139
+ if (typeof e === "string" && e) out.push(`Couldn't read the ${name}: ${e}`);
140
+ }
141
+ return out;
142
+ }
143
+
144
+ /** The empty cleaning-jobs line, in plain words (the maker's own list is empty, not ours). */
145
+ export function noJobsText(brand: string, days = 14): string {
146
+ return `${brand || "The maker"} reports no finished cleaning jobs in the last ${days} days.`;
147
+ }
148
+
149
+ /** The line under it: what would make a job appear, and that the maker has been asked. */
150
+ export function noJobsHint(brand: string): string {
151
+ const who = brand || "the maker";
152
+ return `A job started here or by a schedule is listed once ${who} records it; if this robot was meant to clean, we have asked ${who} why nothing is listed.`;
153
+ }
154
+
155
+ type TWhenRow = { key: string; when: string };
156
+
157
+ /** The robot's status changes (cleaning, charging, returning ...), newest first as sent. */
158
+ export function statusLogRows(records: { statusLog?: TPart<Record<string, any>> } | null | undefined, timeZone?: string): Array<TWhenRow & { status: string }> {
159
+ return items(records?.statusLog).map((l, i) => ({ key: `${l.at ?? ""}-${i}`, when: whenText(l.at, timeZone), status: String(l.status || "—") }));
160
+ }
161
+
162
+ /** What people did to the robot in the maker's app ("Started a cleaning job · Leong"). */
163
+ export function appActionRows(records: { appActions?: TPart<Record<string, any>> } | null | undefined, timeZone?: string): Array<TWhenRow & { action: string; by: string }> {
164
+ return items(records?.appActions).map((a, i) => ({ key: `${a.at ?? ""}-${i}`, when: whenText(a.at, timeZone), action: String(a.action || "—"), by: a.by ? String(a.by) : "—" }));
165
+ }
166
+
167
+ /** The robot's settings, grouped in the API's order ("Cleaning", "Battery", ... "Other settings" last). */
168
+ export function settingGroups(records: { settings?: TPart<Record<string, any>> } | null | undefined) {
169
+ return readingGroups({ readings: records?.settings });
170
+ }
171
+
172
+ /** The current map's walls and zones by kind: "2 virtual walls (Entrance, …)". */
173
+ export function zoneRows(records: { mapLayers?: TPart<Record<string, any>> } | null | undefined): Array<{ key: string; kind: string; names: string }> {
174
+ const zones: Array<{ kind?: string; name?: string }> = items(records?.mapLayers).flatMap((m) => (Array.isArray(m?.zones) ? m.zones : []));
175
+ const words: Record<string, [string, string]> = { wall: ["virtual wall", "virtual walls"], safe: ["safe zone", "safe zones"], slow: ["slow zone", "slow zones"] };
176
+ return (["wall", "safe", "slow"] as const).flatMap((k) => {
177
+ const names = zones.filter((z) => z.kind === k).map((z) => String(z.name ?? ""));
178
+ if (!names.length) return [];
179
+ const [one, many] = words[k]!;
180
+ return [{ key: k, kind: `${names.length} ${names.length === 1 ? one : many}`, names: names.join(", ") }];
181
+ });
182
+ }
183
+
184
+ /** Each version of the current map (re-scan or edit), newest first. */
185
+ export function mapVersionRows(records: { mapVersions?: TPart<Record<string, any>> } | null | undefined, timeZone?: string) {
186
+ return items(records?.mapVersions)
187
+ .slice()
188
+ .sort((a, b) => (isNum(b.version) ? b.version : 0) - (isNum(a.version) ? a.version : 0))
189
+ .map((v, i) => ({ key: `${v.version ?? i}`, version: isNum(v.version) ? `Version ${v.version}` : "Version —", when: whenText(v.createdAt, timeZone), state: v.latest ? "In use" : "Older" }));
190
+ }
191
+
192
+ /** Every map made for the robot's floor: name, size, in use or not. */
193
+ export function mapHistoryRows(records: { mapHistory?: TPart<Record<string, any>> } | null | undefined, timeZone?: string) {
194
+ return items(records?.mapHistory).map((m, i) => ({
195
+ key: `${m.name}-${i}`,
196
+ name: String(m.name ?? "Map"),
197
+ size: isNum(m.areaM2) ? `${m.areaM2} m²` : "—",
198
+ when: `made ${whenText(m.createdAt, timeZone)}${m.updatedAt ? ` · changed ${whenText(m.updatedAt, timeZone)}` : ""}`,
199
+ state: m.latest ? "Latest map" : m.inUse ? "Switched on" : "Old map",
200
+ }));
201
+ }
202
+
203
+ /** The obstacle picture as a data URL, or a sentence when it is too large / not sent. */
204
+ export function obstaclePicture(records: { obstacles?: TPart<Record<string, any>> } | null | undefined): { image: string | null; note: string } {
205
+ const o = items(records?.obstacles)[0];
206
+ if (o && typeof o.image === "string" && /^data:image\/(png|jpeg);base64,/.test(o.image)) return { image: o.image, note: "" };
207
+ return { image: null, note: o?.tooLarge ? "The obstacle picture is too large to show here." : "No obstacle picture was sent." };
208
+ }
209
+
210
+ /** Which records parts this brand sends (a section shows only when its part is there - capability-driven). */
211
+ export function recordParts(records: Record<string, any> | null | undefined): Set<string> {
212
+ return new Set(Object.keys(records ?? {}).filter((k) => records![k] && typeof records![k] === "object" && "items" in records![k]));
213
+ }