@7365admin1/layer-common 4.77.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,11 @@
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
+
3
9
  ## 4.77.0
4
10
 
5
11
  ### Minor Changes
@@ -1920,11 +1926,11 @@
1920
1926
  `utils/console-tier.ts` + `composables/useConsoleTier.ts` mirror the server's
1921
1927
  own rule from two endpoints the console already calls, unprojected:
1922
1928
 
1923
- GET /api/members/user/:user/app/admin the Seven365 staff membership
1924
- 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
1925
1931
 
1926
- owner = member.type === "admin" && role.type === "admin" && role.default === true
1927
- 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"
1928
1934
 
1929
1935
  `role.default` is the marker because it is the only property of a platform
1930
1936
  staff role no API caller can set - `role.controller.ts` validates create and
@@ -671,6 +671,13 @@
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
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; }
674
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); }
675
682
  .hero-rec { margin-top: 16px; }
676
683
  .hero-rec__table { width: 100%; border-collapse: collapse; font-size: 13px; }
@@ -1,8 +1,11 @@
1
1
  <template>
2
2
  <!--
3
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.
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.
6
9
  A part the maker refused is said in words, never left blank.
7
10
  -->
8
11
  <section v-if="records?.supported" class="hero-rec" aria-label="Cleaning records">
@@ -13,7 +16,9 @@
13
16
  <v-btn variant="text" size="small" :loading="loading" @click="load(true)">Refresh</v-btn>
14
17
  </div>
15
18
  <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>
19
+ <div v-if="!jobs.length && !records.tasks?.error" class="hero-sched__empty">
20
+ {{ noJobs }} {{ noJobsMore }}
21
+ </div>
17
22
  <div v-else class="hero-rec__wrap">
18
23
  <table class="hero-rec__table">
19
24
  <thead>
@@ -38,6 +43,14 @@
38
43
  </div>
39
44
  <v-btn v-if="alerts.length > 5" variant="text" size="small" @click="allAlerts = !allAlerts">{{ allAlerts ? "Show fewer" : `See all ${alerts.length}` }}</v-btn>
40
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
+
41
54
  <details class="hero-can hero-can--fold" :open="attention.length > 0">
42
55
  <summary class="hero-can__title">
43
56
  Robot readings{{ attention.length ? ` · needs attention: ${attention.join(", ")}` : "" }}
@@ -68,21 +81,84 @@
68
81
  <div v-if="!lifts.length" class="hero-sched__empty">No lifts linked to this robot's building.</div>
69
82
  <div v-for="l in lifts" :key="l.key" class="hero-sched__row"><b>{{ l.name }}</b><span>{{ l.state }}</span></div>
70
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>
71
127
  </section>
72
128
  </template>
73
129
 
74
130
  <script setup lang="ts">
75
131
  import { computed, ref, watch } from "vue";
76
132
  import { robotErrorMessage } from "../../utils/robot-display";
77
- import { alertHistoryRows, attentionReadings, jobRows, liftRows, partRows, powerRows, readingGroups, recordErrors } from "../../utils/robot-records";
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";
78
153
 
79
- const props = defineProps<{ robotId: string; makerApp: string }>();
154
+ const props = defineProps<{ robotId: string; makerApp: string; brand?: string }>();
80
155
  const { getRobotRecords } = useRobots();
81
156
 
82
157
  const records = ref<Record<string, any> | null>(null);
83
158
  const loading = ref(false);
84
159
  const allJobs = ref(false);
85
160
  const allAlerts = ref(false);
161
+ const allLog = ref(false);
86
162
 
87
163
  // ponytail: read on open and on Refresh only - the maker rate-limits these reads (the API caches 2 min).
88
164
  async function load(force = false) {
@@ -104,6 +180,7 @@ watch(
104
180
  records.value = null;
105
181
  allJobs.value = false;
106
182
  allAlerts.value = false;
183
+ allLog.value = false;
107
184
  load(true);
108
185
  },
109
186
  { immediate: true },
@@ -119,4 +196,15 @@ const parts = computed(() => partRows(records.value));
119
196
  const power = computed(() => powerRows(records.value));
120
197
  const lifts = computed(() => liftRows(records.value));
121
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));
122
210
  </script>
@@ -113,12 +113,15 @@
113
113
  :points="selectedMap?.points ?? []"
114
114
  :overlay="selectedMap?.overlay ?? null"
115
115
  :areas="selectedMap?.areas ?? []"
116
+ :zones="selectedMap?.zones ?? []"
117
+ :taught-route="selectedMap?.taughtRoute ?? []"
116
118
  :route-moving="statusMode === 'WORKING' && robotOnShownMap"
117
119
  :spot="spot"
118
120
  :pick="spotOn"
119
121
  :tick-ms="tickMs"
120
122
  @pick="(p) => spot.push(p)"
121
123
  />
124
+ <p v-if="mapLegend" class="hero-muted hero-detail__lag">{{ mapLegend }}</p>
122
125
  <p v-if="selectedMap?.approximate" class="hero-muted hero-detail__lag">
123
126
  Robot position is approximate: {{ brand }} has not yet confirmed which corner their map is measured from.
124
127
  </p>
@@ -193,7 +196,7 @@
193
196
  </section>
194
197
 
195
198
  <!-- 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" />
199
+ <RobotCleaningRecords v-if="!noInterface && record && robotType !== 'security'" :robot-id="robotId" :maker-app="makerApp" :brand="brand" />
197
200
 
198
201
  <!-- What THIS robot (brand + model) can do, in our words; folded so it never pushes the camera down. -->
199
202
  <details v-if="abilities.length" class="hero-can hero-can--fold">
@@ -301,7 +304,7 @@ import RobotLiveView from "./LiveView.vue";
301
304
  import RobotManualDrive from "./ManualDrive.vue";
302
305
  import RobotPointPicker from "./PointPicker.vue";
303
306
  import { faultPhotos } from "../../utils/robot-control";
304
- import { robotMapDefault, robotMapViews } from "../../utils/robot-maps";
307
+ import { cleanAreaChoices, cleanAreaCommand, mapLayerLegend, robotMapDefault, robotMapViews } from "../../utils/robot-maps";
305
308
  import useRobotLive, { siteStatesFeed } from "../../composables/useRobotLive";
306
309
  import {
307
310
  attentionText,
@@ -574,6 +577,7 @@ const mapItems = ref<Array<Record<string, any>>>([]);
574
577
  const mapViews = computed(() => robotMapViews(mapItems.value, live.value?.mapId ?? null, patrol.value?.routes ?? []));
575
578
  const selectedMapId = ref<string | null>(null);
576
579
  const selectedMap = computed(() => mapViews.value.find((m) => m.mapId === selectedMapId.value) ?? null);
580
+ const mapLegend = computed(() => mapLayerLegend(selectedMap.value));
577
581
  watch(mapViews, (views) => {
578
582
  if (!views.some((m) => m.mapId === selectedMapId.value)) selectedMapId.value = robotMapDefault(mapItems.value, views);
579
583
  });
@@ -717,10 +721,9 @@ async function send(kind: TRobotCommandKind) {
717
721
  }
718
722
  // Clean area (cleaning robots): the API cleans named areas, so pick them from the robot's map.
719
723
  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) ?? [];
724
+ const { areas: zones, empty } = cleanAreaChoices(mapViews.value, selectedMapId.value, makerApp.value);
722
725
  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 };
726
+ commandResult.value = { text: empty, warn: true };
724
727
  return;
725
728
  }
726
729
  confirming.value = { kind: "area", body: {}, text: { title: "", body: "", action: "" }, zones, zoneNames: [zones[0]!] };
@@ -753,13 +756,11 @@ const confirming = ref<{
753
756
  function pickZones(names: unknown) {
754
757
  const c = confirming.value;
755
758
  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." };
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 };
763
764
  }
764
765
  /** The route picked in Start patrol's confirm: its exact words, and the body naming it. */
765
766
  function pickRoute(id: unknown) {
@@ -10,6 +10,13 @@
10
10
  -->
11
11
  <RobotFloorPlan detail :pick="pick" :image="image" :aspect="aspect" @pick="emit('pick', $event)">
12
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>
13
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>
14
21
  <template v-for="p in points" :key="p.targetPointId">
15
22
  <span class="hero-plan__pt" :style="{ left: `${p.x}%`, top: `${p.y}%` }" aria-hidden="true" />
@@ -61,6 +68,9 @@ const props = withDefaults(
61
68
  /** The maker's coloured area layer over the map image (Ounitech), and its areas' names + label spots. */
62
69
  overlay?: string | null;
63
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 }>;
64
74
  /** Saved points on the map shown (0-100 shares). */
65
75
  points?: Array<{ targetPointId: string; name: string; x: number; y: number }>;
66
76
  /** The route's dash walks only while the robot patrols. */
@@ -72,8 +82,9 @@ const props = withDefaults(
72
82
  /** The last reading kept through a short gap: drawn faded, never as live. */
73
83
  stale?: boolean;
74
84
  }>(),
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 },
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 },
76
86
  );
87
+ const svgPoints = (pts: Array<{ x: number; y: number }>) => pts.map((p) => `${p.x},${p.y}`).join(" ");
77
88
  const placedAreas = computed(() => (props.image ? props.areas.filter((a) => a.at) : []));
78
89
  const emit = defineEmits<{ pick: [point: { x: number; y: number }] }>();
79
90
  // The tag stays inside the plan (flipped left near the right edge, below near the top) - never clipped.
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.77.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.",
@@ -28,8 +28,36 @@ export type TRobotMapView = {
28
28
  areas: Array<{ name: string; at: { x: number; y: number } | null }>;
29
29
  /** The maker has not confirmed the map's corner, so the robot pin may be off: say "position approximate". */
30
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 }>;
31
35
  };
32
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
+
33
61
  const isNum = (v: unknown): v is number => typeof v === "number" && Number.isFinite(v);
34
62
  const levelName = (n: number) => (n > 0 ? `L${n}` : n < 0 ? `B${-n}` : "G");
35
63
  const TYPE_WORDS: Readonly<Record<number, string>> = { 0: "Point", 3: "Lift", 4: "Loading", 6: "Access door", 10: "Charging" };
@@ -90,6 +118,8 @@ export function robotMapViews(
90
118
  .filter((a: any) => typeof a?.name === "string" && a.name.trim())
91
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 })),
92
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 })),
93
123
  };
94
124
  });
95
125
  }
@@ -119,3 +149,28 @@ export function robotMapsByFloor(views: TRobotMapView[]): Map<number, TRobotMapV
119
149
  for (const v of views) if (v.floorNo !== null) out.set(v.floorNo, [...(out.get(v.floorNo) ?? []), v]);
120
150
  return out;
121
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
+ }
@@ -24,7 +24,8 @@ export function whenText(iso: unknown, timeZone?: string): string {
24
24
  const t = typeof iso === "string" ? Date.parse(iso) : NaN;
25
25
  if (!Number.isFinite(t)) return "—";
26
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 } : {}) })
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" })
28
29
  .formatToParts(t)
29
30
  .map((p) => [p.type, p.value]),
30
31
  );
@@ -124,6 +125,12 @@ export function recordErrors(records: Record<string, any> | null | undefined): s
124
125
  lifts: "lifts",
125
126
  readings: "robot readings",
126
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",
127
134
  };
128
135
  if (!records) return [];
129
136
  const out = typeof records.error === "string" && records.error ? [records.error] : [];
@@ -133,3 +140,74 @@ export function recordErrors(records: Record<string, any> | null | undefined): s
133
140
  }
134
141
  return out;
135
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
+ }