@7365admin1/layer-common 4.76.0 → 4.77.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,11 @@
1
1
  # @iservice365/layer-common
2
2
 
3
+ ## 4.77.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 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.
8
+
3
9
  ## 4.76.0
4
10
 
5
11
  ### Minor Changes
@@ -1914,11 +1920,11 @@
1914
1920
  `utils/console-tier.ts` + `composables/useConsoleTier.ts` mirror the server's
1915
1921
  own rule from two endpoints the console already calls, unprojected:
1916
1922
 
1917
- GET /api/members/user/:user/app/admin the Seven365 staff membership
1918
- GET /api/roles/id/:role that membership's role document
1923
+ GET /api/members/user/:user/app/admin the Seven365 staff membership
1924
+ GET /api/roles/id/:role that membership's role document
1919
1925
 
1920
- owner = member.type === "admin" && role.type === "admin" && role.default === true
1921
- staff = member.type === "admin" && role.type === "admin"
1926
+ owner = member.type === "admin" && role.type === "admin" && role.default === true
1927
+ staff = member.type === "admin" && role.type === "admin"
1922
1928
 
1923
1929
  `role.default` is the marker because it is the only property of a platform
1924
1930
  staff role no API caller can set - `role.controller.ts` validates create and
@@ -670,6 +670,15 @@
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__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); }
675
+ .hero-rec { margin-top: 16px; }
676
+ .hero-rec__table { width: 100%; border-collapse: collapse; font-size: 13px; }
677
+ .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); }
678
+ .hero-rec__table td { padding: 4px 6px; border-bottom: 1px solid var(--hero-divider); vertical-align: top; }
679
+ .hero-rec__wrap { overflow-x: auto; }
680
+ .hero-rec__grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 2px 16px; }
681
+ .hero-rec__attn { color: var(--hero-accent); font-weight: 600; }
673
682
  .hero-plan:fullscreen { background-color: var(--hero-bg); }
674
683
  .hero-plan__corner { position: absolute; width: 14px; height: 14px; }
675
684
  .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,122 @@
1
+ <template>
2
+ <!--
3
+ Robot detail's records (`/robots/:id/records`, read-only): recent cleaning
4
+ jobs, alert history, then - folded - every reading the robot sends, parts
5
+ wear, the power schedule and lifts. Brands without records render nothing.
6
+ A part the maker refused is said in words, never left blank.
7
+ -->
8
+ <section v-if="records?.supported" class="hero-rec" aria-label="Cleaning records">
9
+ <div class="hero-aside__head">
10
+ <span class="hero-section__title">Recent cleaning jobs</span>
11
+ <span class="hero-aside__hint">last 14 days · from {{ makerApp }}</span>
12
+ <span class="hero-head__spacer" />
13
+ <v-btn variant="text" size="small" :loading="loading" @click="load(true)">Refresh</v-btn>
14
+ </div>
15
+ <div v-for="e in errors" :key="e" class="hero-notice hero-notice--error" role="status">{{ e }}</div>
16
+ <div v-if="!jobs.length" class="hero-sched__empty">No cleaning jobs in the last 14 days.</div>
17
+ <div v-else class="hero-rec__wrap">
18
+ <table class="hero-rec__table">
19
+ <thead>
20
+ <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>
21
+ </thead>
22
+ <tbody>
23
+ <tr v-for="j in shownJobs" :key="j.key">
24
+ <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>
25
+ </tr>
26
+ </tbody>
27
+ </table>
28
+ <v-btn v-if="jobs.length > 5" variant="text" size="small" @click="allJobs = !allJobs">{{ allJobs ? "Show fewer" : `See all ${jobs.length}` }}</v-btn>
29
+ </div>
30
+
31
+ <div class="hero-section__title mt-4">Alert history</div>
32
+ <div class="hero-sched__note">What the robot reported in the last 30 days (open alerts are listed above).</div>
33
+ <div v-if="!alerts.length" class="hero-sched__empty">No alerts in the last 30 days.</div>
34
+ <div v-for="a in shownAlerts" :key="a.key" class="hero-sched__row hero-sched__row--alert" :title="a.vendorText">
35
+ <b class="hero-detail__alertcode">{{ a.code }}</b>
36
+ <span>{{ a.level ? `${a.level} · ` : "" }}{{ a.message }}</span>
37
+ <span>{{ a.when }} · {{ a.state }}</span>
38
+ </div>
39
+ <v-btn v-if="alerts.length > 5" variant="text" size="small" @click="allAlerts = !allAlerts">{{ allAlerts ? "Show fewer" : `See all ${alerts.length}` }}</v-btn>
40
+
41
+ <details class="hero-can hero-can--fold" :open="attention.length > 0">
42
+ <summary class="hero-can__title">
43
+ Robot readings{{ attention.length ? ` · needs attention: ${attention.join(", ")}` : "" }}
44
+ </summary>
45
+ <div v-if="!groups.length" class="hero-sched__empty">The robot sent no readings.</div>
46
+ <div v-for="g in groups" :key="g.group" class="mb-2">
47
+ <div class="hero-sched__note">{{ g.group }}</div>
48
+ <div class="hero-rec__grid">
49
+ <div v-for="r in g.rows" :key="r.key" class="hero-can__row">
50
+ <span class="hero-can__label">{{ r.label }}</span>
51
+ <span :class="{ 'hero-rec__attn': r.attention }">{{ r.value }}</span>
52
+ </div>
53
+ </div>
54
+ </div>
55
+ </details>
56
+
57
+ <details class="hero-can hero-can--fold">
58
+ <summary class="hero-can__title">Parts wear, power schedule and lifts</summary>
59
+ <div class="hero-sched__note">Parts wear (hours worked since last reset in {{ makerApp }})</div>
60
+ <div v-if="!parts.length" class="hero-sched__empty">No parts reported.</div>
61
+ <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>
62
+ <div class="hero-sched__note mt-2">Timed switch on / off</div>
63
+ <div v-if="!power.length" class="hero-sched__empty">No timed switch on / off.</div>
64
+ <div v-for="p in power" :key="p.key" class="hero-sched__row">
65
+ <b>{{ p.days }}</b><span>{{ p.time }}</span><span>{{ p.action }}{{ p.enabled ? "" : " · off" }}</span>
66
+ </div>
67
+ <div class="hero-sched__note mt-2">Lifts linked in {{ makerApp }}</div>
68
+ <div v-if="!lifts.length" class="hero-sched__empty">No lifts linked to this robot's building.</div>
69
+ <div v-for="l in lifts" :key="l.key" class="hero-sched__row"><b>{{ l.name }}</b><span>{{ l.state }}</span></div>
70
+ </details>
71
+ </section>
72
+ </template>
73
+
74
+ <script setup lang="ts">
75
+ import { computed, ref, watch } from "vue";
76
+ import { robotErrorMessage } from "../../utils/robot-display";
77
+ import { alertHistoryRows, attentionReadings, jobRows, liftRows, partRows, powerRows, readingGroups, recordErrors } from "../../utils/robot-records";
78
+
79
+ const props = defineProps<{ robotId: string; makerApp: string }>();
80
+ const { getRobotRecords } = useRobots();
81
+
82
+ const records = ref<Record<string, any> | null>(null);
83
+ const loading = ref(false);
84
+ const allJobs = ref(false);
85
+ const allAlerts = ref(false);
86
+
87
+ // ponytail: read on open and on Refresh only - the maker rate-limits these reads (the API caches 2 min).
88
+ async function load(force = false) {
89
+ if (loading.value && !force) return;
90
+ loading.value = true;
91
+ try {
92
+ records.value = await getRobotRecords(props.robotId);
93
+ } catch (e: any) {
94
+ // An API without the records route (404) shows no card; any other failure is said.
95
+ const status = e?.statusCode ?? e?.status ?? e?.response?.status;
96
+ records.value = status === 404 ? null : { supported: true, error: `Couldn't read the records: ${robotErrorMessage(e)}` };
97
+ } finally {
98
+ loading.value = false;
99
+ }
100
+ }
101
+ watch(
102
+ () => props.robotId,
103
+ () => {
104
+ records.value = null;
105
+ allJobs.value = false;
106
+ allAlerts.value = false;
107
+ load(true);
108
+ },
109
+ { immediate: true },
110
+ );
111
+
112
+ const jobs = computed(() => jobRows(records.value));
113
+ const shownJobs = computed(() => (allJobs.value ? jobs.value : jobs.value.slice(0, 5)));
114
+ const alerts = computed(() => alertHistoryRows(records.value));
115
+ const shownAlerts = computed(() => (allAlerts.value ? alerts.value : alerts.value.slice(0, 5)));
116
+ const groups = computed(() => readingGroups(records.value));
117
+ const attention = computed(() => attentionReadings(records.value));
118
+ const parts = computed(() => partRows(records.value));
119
+ const power = computed(() => powerRows(records.value));
120
+ const lifts = computed(() => liftRows(records.value));
121
+ const errors = computed(() => recordErrors(records.value));
122
+ </script>
@@ -111,12 +111,17 @@
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 ?? []"
114
116
  :route-moving="statusMode === 'WORKING' && robotOnShownMap"
115
117
  :spot="spot"
116
118
  :pick="spotOn"
117
119
  :tick-ms="tickMs"
118
120
  @pick="(p) => spot.push(p)"
119
121
  />
122
+ <p v-if="selectedMap?.approximate" class="hero-muted hero-detail__lag">
123
+ Robot position is approximate: {{ brand }} has not yet confirmed which corner their map is measured from.
124
+ </p>
120
125
  <div v-if="spotOn" class="hero-spotbar">
121
126
  <v-btn color="primary" variant="flat" :disabled="!spot.length || busy === 'spot'" :loading="busy === 'spot'" @click="sendSpot">
122
127
  Send spot clean · {{ spot.length }} points
@@ -187,6 +192,9 @@
187
192
  </div>
188
193
  </section>
189
194
 
195
+ <!-- Cleaning robots: recent jobs, alert history, every reading, parts wear, power schedule, lifts (read-only). -->
196
+ <RobotCleaningRecords v-if="!noInterface && record && robotType !== 'security'" :robot-id="robotId" :maker-app="makerApp" />
197
+
190
198
  <!-- What THIS robot (brand + model) can do, in our words; folded so it never pushes the camera down. -->
191
199
  <details v-if="abilities.length" class="hero-can hero-can--fold">
192
200
  <summary class="hero-can__title">What this robot can do</summary>
@@ -199,6 +207,19 @@
199
207
  <v-dialog v-model="confirmOpen" width="440">
200
208
  <div v-if="confirming" class="hero-dialog" role="alertdialog" aria-labelledby="hero-confirm-title">
201
209
  <div id="hero-confirm-title" class="hero-dialog__title">{{ confirming.text.title }}</div>
210
+ <v-select
211
+ v-if="confirming.zones?.length"
212
+ v-model="confirming.zoneNames"
213
+ :items="confirming.zones"
214
+ label="Areas to clean"
215
+ multiple
216
+ chips
217
+ closable-chips
218
+ density="compact"
219
+ hide-details
220
+ class="mb-2"
221
+ @update:model-value="pickZones"
222
+ />
202
223
  <v-select
203
224
  v-if="confirming.routes?.length"
204
225
  v-model="confirming.routeId"
@@ -214,7 +235,7 @@
214
235
  <div class="hero-dialog__body">{{ confirming.text.body }}</div>
215
236
  <div class="hero-dialog__actions">
216
237
  <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>
238
+ <v-btn color="primary" variant="flat" :disabled="!!confirming.zones && !confirming.zoneNames?.length" @click="confirmSend">{{ confirming.text.action }}</v-btn>
218
239
  </div>
219
240
  </div>
220
241
  </v-dialog>
@@ -272,6 +293,7 @@ import RobotNoMonitoring from "./NoMonitoring.vue";
272
293
  import RobotCommandBar from "./CommandBar.vue";
273
294
  import RobotViewOnlyNotice from "./ViewOnlyNotice.vue";
274
295
  import RobotLivePlan from "./LivePlan.vue";
296
+ import RobotCleaningRecords from "./CleaningRecords.vue";
275
297
  import RobotToast from "./Toast.vue";
276
298
  import RobotForm from "./Form.vue";
277
299
  import RobotPatrolHistory from "./PatrolHistory.vue";
@@ -495,6 +517,10 @@ const planTitle = computed(() => {
495
517
  const planHint = computed(() => {
496
518
  if (spotOn.value) return "Click points on the plan, then send";
497
519
  if (noInterface.value) return "No position feed for this model";
520
+ if (selectedMap.value && robotType.value !== "security") {
521
+ const n = selectedMap.value.areas.length;
522
+ return `${n} cleaning area${n === 1 ? "" : "s"}`;
523
+ }
498
524
  if (selectedMap.value) return `${selectedMap.value.points.length} saved points · ${mapRoutes.value.length} routes`;
499
525
  if (route.value) return `${route.value.checkpoints.length} checkpoints · ${floorText.value}`;
500
526
  return ""; // the Floor cell above already says it
@@ -689,6 +715,19 @@ async function send(kind: TRobotCommandKind) {
689
715
  return;
690
716
  }
691
717
  }
718
+ // Clean area (cleaning robots): the API cleans named areas, so pick them from the robot's map.
719
+ if (kind === "area" && robotType.value !== "security") {
720
+ const map = mapViews.value.find((m) => m.current) ?? selectedMap.value;
721
+ const zones = map?.areas.map((a) => a.name) ?? [];
722
+ if (!zones.length) {
723
+ commandResult.value = { text: `There are no cleaning areas on ${map ? map.label : "this robot's map"} yet. Areas are drawn in ${makerApp.value}.`, warn: true };
724
+ return;
725
+ }
726
+ confirming.value = { kind: "area", body: {}, text: { title: "", body: "", action: "" }, zones, zoneNames: [zones[0]!] };
727
+ pickZones([zones[0]!]);
728
+ confirmOpen.value = true;
729
+ return;
730
+ }
692
731
  const body = commandBody(kind, live.value);
693
732
  if (!body) {
694
733
  toast.value = `${commandLabel(kind)}: the robot's position is not known yet.`;
@@ -705,8 +744,23 @@ const confirming = ref<{
705
744
  text: { title: string; body: string; action: string };
706
745
  routes?: TPatrolRouteView[];
707
746
  routeId?: string;
747
+ /** Clean area: the map's area names, and the ones picked. */
748
+ zones?: string[];
749
+ zoneNames?: string[];
708
750
  what?: string;
709
751
  } | null>(null);
752
+ /** The areas picked in Clean area's confirm: the body names them and the sentence says them. */
753
+ function pickZones(names: unknown) {
754
+ const c = confirming.value;
755
+ if (!c?.zones) return;
756
+ const picked = (Array.isArray(names) ? names : []).filter((n): n is string => typeof n === "string" && c.zones!.includes(n));
757
+ const list = picked.length > 2 ? `${picked.slice(0, -1).join(", ")} and ${picked.at(-1)}` : picked.join(" and ");
758
+ c.zoneNames = picked;
759
+ c.body = { kind: "area", areas: picked };
760
+ c.what = picked.length ? `cleaning ${list}` : undefined;
761
+ const text = commandConfirm("area", { type: robotType.value, name: record.value?.name });
762
+ c.text = { ...text, body: picked.length ? `${record.value?.name ?? "The robot"} starts cleaning ${list}, one pass.` : "Pick at least one area." };
763
+ }
710
764
  /** The route picked in Start patrol's confirm: its exact words, and the body naming it. */
711
765
  function pickRoute(id: unknown) {
712
766
  const c = confirming.value;
@@ -9,6 +9,8 @@
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
+ <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
14
  <template v-for="p in points" :key="p.targetPointId">
13
15
  <span class="hero-plan__pt" :style="{ left: `${p.x}%`, top: `${p.y}%` }" aria-hidden="true" />
14
16
  <span class="hero-plan__ptname" :style="{ left: `${p.x}%`, top: `${p.y}%` }">{{ p.name }}</span>
@@ -56,6 +58,9 @@ const props = withDefaults(
56
58
  /** The map shown: the robot's own image and its width / height. */
57
59
  image?: string | null;
58
60
  aspect?: number | null;
61
+ /** The maker's coloured area layer over the map image (Ounitech), and its areas' names + label spots. */
62
+ overlay?: string | null;
63
+ areas?: Array<{ name: string; at: { x: number; y: number } | null }>;
59
64
  /** Saved points on the map shown (0-100 shares). */
60
65
  points?: Array<{ targetPointId: string; name: string; x: number; y: number }>;
61
66
  /** The route's dash walks only while the robot patrols. */
@@ -67,8 +72,9 @@ const props = withDefaults(
67
72
  /** The last reading kept through a short gap: drawn faded, never as live. */
68
73
  stale?: boolean;
69
74
  }>(),
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 },
75
+ { stale: false, name: "", statusLabel: "", point: null, heading: 0, trail: () => [], readout: "", lost: false, route: null, routes: null, image: null, aspect: null, overlay: null, areas: () => [], points: () => [], routeMoving: false, spot: () => [], pick: false, tickMs: 1200 },
71
76
  );
77
+ const placedAreas = computed(() => (props.image ? props.areas.filter((a) => a.at) : []));
72
78
  const emit = defineEmits<{ pick: [point: { x: number; y: number }] }>();
73
79
  // The tag stays inside the plan (flipped left near the right edge, below near the top) - never clipped.
74
80
  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.77.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,6 +22,12 @@ 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;
25
31
  };
26
32
 
27
33
  const isNum = (v: unknown): v is number => typeof v === "number" && Number.isFinite(v);
@@ -47,7 +53,8 @@ export function robotMapShare(map: Record<string, any> | null | undefined, wx: u
47
53
  * `/robots/:id/maps` items (PadBot: `mapId` + `imageUrl`) as selector entries,
48
54
  * in PadBot's order. Named by PadBot's display name; without one, "Map N"
49
55
  * 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).
56
+ * Ounitech maps arrive in the same shape (the API adds mapId / imageUrl / size),
57
+ * plus their area layer, area names and "position approximate".
51
58
  */
52
59
  export function robotMapViews(
53
60
  items: Array<Record<string, any>> | null | undefined,
@@ -78,6 +85,11 @@ export function robotMapViews(
78
85
  current: !!currentMapId && m.mapId === currentMapId,
79
86
  routeNames,
80
87
  points,
88
+ overlay: typeof m.partitionImageUrl === "string" && /^https?:\/\//i.test(m.partitionImageUrl) ? m.partitionImageUrl : null,
89
+ areas: (Array.isArray(m.areas) ? m.areas : [])
90
+ .filter((a: any) => typeof a?.name === "string" && a.name.trim())
91
+ .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 })),
92
+ approximate: m.positionApproximate === true,
81
93
  };
82
94
  });
83
95
  }
@@ -0,0 +1,135 @@
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
+ new Intl.DateTimeFormat("en-GB", { day: "numeric", month: "numeric", hour: "2-digit", minute: "2-digit", hourCycle: "h23", ...(timeZone ? { timeZone } : {}) })
28
+ .formatToParts(t)
29
+ .map((p) => [p.type, p.value]),
30
+ );
31
+ return `${parts.day} ${MONTHS[Number(parts.month) - 1]}, ${parts.hour}:${parts.minute}`;
32
+ }
33
+
34
+ export type TJobRow = { key: string; when: string; area: string; job: string; duration: string; size: string; done: string; result: string; by: string };
35
+
36
+ /** Cleaning jobs, newest first as the API sends them. */
37
+ export function jobRows(records: { tasks?: TPart<Record<string, any>> } | null | undefined, timeZone?: string): TJobRow[] {
38
+ return items(records?.tasks).map((t, i) => {
39
+ const floor = isNum(t.floorNo) ? (t.floorNo > 0 ? `L${t.floorNo}` : t.floorNo < 0 ? `B${-t.floorNo}` : "G") : "";
40
+ const area = [t.map, floor].filter(Boolean).join(" · ") || "—";
41
+ const size = isNum(t.areaM2) ? (isNum(t.plannedM2) && t.plannedM2 > 0 ? `${t.areaM2} of ${t.plannedM2} m²` : `${t.areaM2} m²`) : "—";
42
+ return {
43
+ key: `${t.id ?? ""}-${i}`,
44
+ when: whenText(t.start, timeZone),
45
+ area,
46
+ job: [t.job, t.mode].filter(Boolean).join(" · ") || "—",
47
+ duration: durationText(t.durationSec),
48
+ size,
49
+ done: isNum(t.percent) ? `${Math.round(t.percent)}%` : "—",
50
+ result: t.result || "—",
51
+ by: t.source || "—",
52
+ };
53
+ });
54
+ }
55
+
56
+ export type TAlertRow = { key: string; when: string; code: string; level: string; message: string; state: string; vendorText: string };
57
+
58
+ export function alertHistoryRows(records: { alerts?: TPart<Record<string, any>> } | null | undefined, timeZone?: string): TAlertRow[] {
59
+ return items(records?.alerts).map((a, i) => ({
60
+ key: `${a.code ?? ""}-${a.at ?? ""}-${i}`,
61
+ when: whenText(a.at, timeZone),
62
+ code: a.code ? String(a.code) : "—",
63
+ level: a.level || "",
64
+ message: a.message || a.vendorText || "—",
65
+ state: a.resolved ? "Resolved" : "Reported",
66
+ vendorText: a.vendorText && a.vendorText !== a.message ? String(a.vendorText) : "",
67
+ }));
68
+ }
69
+
70
+ /** Readings grouped in the API's order ("Battery", "Water", ...), base station last. */
71
+ export function readingGroups(records: { readings?: TPart<Record<string, any>>; station?: TPart<Record<string, any>> } | null | undefined) {
72
+ const rows = [...items(records?.readings), ...items(records?.station).flatMap((s) => (Array.isArray(s?.readings) ? s.readings : []))];
73
+ const status = items(records?.station)[0]?.status;
74
+ if (status) rows.push({ key: "station-status", group: "Base station", label: "Station status", value: String(status), attention: false });
75
+ const groups: Array<{ group: string; rows: Array<{ key: string; label: string; value: string; attention: boolean }> }> = [];
76
+ for (const r of rows) {
77
+ const g = String(r?.group || "Other readings");
78
+ let hit = groups.find((x) => x.group === g);
79
+ if (!hit) groups.push((hit = { group: g, rows: [] }));
80
+ hit.rows.push({ key: String(r.key ?? r.label), label: String(r.label ?? r.key), value: String(r.value ?? ""), attention: r.attention === true });
81
+ }
82
+ return groups;
83
+ }
84
+
85
+ /** Readings that need a person ("Dust box fitted: No"), for the card's one-line summary. */
86
+ export function attentionReadings(records: Parameters<typeof readingGroups>[0]): string[] {
87
+ return readingGroups(records).flatMap((g) => g.rows.filter((r) => r.attention).map((r) => `${r.label}: ${r.value}`));
88
+ }
89
+
90
+ export function partRows(records: { consumables?: TPart<Record<string, any>> } | null | undefined) {
91
+ return items(records?.consumables).map((c, i) => ({
92
+ key: `${c.part}-${i}`,
93
+ part: String(c.part ?? "Part"),
94
+ worked: isNum(c.hours) ? `${c.hours} h` : "—",
95
+ days: isNum(c.days) ? `${c.days} days` : "—",
96
+ }));
97
+ }
98
+
99
+ export function powerRows(records: { power?: TPart<Record<string, any>> } | null | undefined) {
100
+ return items(records?.power).map((p, i) => ({
101
+ key: `${p.time}-${i}`,
102
+ days: dayRange(p.days),
103
+ time: p.time || "—",
104
+ action: p.action === "on" ? "Switch on" : "Switch off",
105
+ enabled: p.enabled !== false,
106
+ }));
107
+ }
108
+
109
+ export function liftRows(records: { lifts?: TPart<Record<string, any>> } | null | undefined) {
110
+ return items(records?.lifts).map((l, i) => ({
111
+ key: `${l.name}-${i}`,
112
+ name: String(l.name ?? "Lift"),
113
+ state: `${l.online ? "Online" : "Offline"}${l.enabled === false ? " · disabled" : ""}${isNum(l.floorNo) ? ` · at L${l.floorNo}` : ""}`,
114
+ }));
115
+ }
116
+
117
+ /** "Couldn't read the cleaning records: …" per refused part, so a gap is said, never silent. */
118
+ export function recordErrors(records: Record<string, any> | null | undefined): string[] {
119
+ const names: Record<string, string> = {
120
+ tasks: "cleaning jobs",
121
+ alerts: "alert history",
122
+ consumables: "parts wear",
123
+ power: "power schedule",
124
+ lifts: "lifts",
125
+ readings: "robot readings",
126
+ station: "base station",
127
+ };
128
+ if (!records) return [];
129
+ const out = typeof records.error === "string" && records.error ? [records.error] : [];
130
+ for (const [k, name] of Object.entries(names)) {
131
+ const e = records[k]?.error;
132
+ if (typeof e === "string" && e) out.push(`Couldn't read the ${name}: ${e}`);
133
+ }
134
+ return out;
135
+ }