@7365admin1/layer-common 4.52.0 → 4.53.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.53.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 74aabc6: PadBot security robots on the HERO robot screens: Add robot labels the fields "AccessKey ID" / "AccessKey Secret" and picks the robot from the account; robot detail offers Start patrol / Resume patrol (brands with `patrolControl`), Live video (minted per click), Mark resolved on an alarm, an ALARM badge for security alerts, and a Patrol history panel. Pause / Resume now need the brand's `pauseResume`, Cancel its `pauseProcess` (no change for existing brands).
8
+
3
9
  ## 4.52.0
4
10
 
5
11
  ### Minor Changes
@@ -1557,11 +1563,11 @@
1557
1563
  `utils/console-tier.ts` + `composables/useConsoleTier.ts` mirror the server's
1558
1564
  own rule from two endpoints the console already calls, unprojected:
1559
1565
 
1560
- GET /api/members/user/:user/app/admin the Seven365 staff membership
1561
- GET /api/roles/id/:role that membership's role document
1566
+ GET /api/members/user/:user/app/admin the Seven365 staff membership
1567
+ GET /api/roles/id/:role that membership's role document
1562
1568
 
1563
- owner = member.type === "admin" && role.type === "admin" && role.default === true
1564
- staff = member.type === "admin" && role.type === "admin"
1569
+ owner = member.type === "admin" && role.type === "admin" && role.default === true
1570
+ staff = member.type === "admin" && role.type === "admin"
1565
1571
 
1566
1572
  `role.default` is the marker because it is the only property of a platform
1567
1573
  staff role no API caller can set - `role.controller.ts` validates create and
@@ -757,7 +757,7 @@
757
757
  .hero .v-btn.hero-ghost { color: var(--hero-accent); padding-inline: 4px; }
758
758
  .hero .v-btn.hero-ghost:hover { background: color-mix(in srgb, var(--hero-accent) 10%, transparent); }
759
759
 
760
- .hero-faultbar { display: grid; grid-template-columns: auto 1fr auto; gap: 14px; align-items: center; padding: 12px 14px; border: 2px solid var(--hero-accent); }
760
+ .hero-faultbar { display: grid; grid-template-columns: auto 1fr; grid-auto-flow: column; grid-auto-columns: auto; gap: 14px; align-items: center; padding: 12px 14px; border: 2px solid var(--hero-accent); }
761
761
  .hero-faultbar__code { font-family: var(--hero-font); font-weight: 800; color: var(--hero-accent); font-size: 18px; }
762
762
  .hero-faultbar__msg { font-weight: 600; }
763
763
  .hero-faultbar__since { opacity: 0.65; font-size: 12px; }
@@ -1234,3 +1234,6 @@ div.hero-editor__wp.hero-editor__sel, div.hero-editor__fid.hero-editor__sel { ou
1234
1234
  }
1235
1235
  .hero-cell__shimmer { display: none; }
1236
1236
  }
1237
+
1238
+ /* PadBot security extras: live video button row under the facts. */
1239
+ .hero-detail__video { margin: 10px 0 4px; }
@@ -27,7 +27,9 @@
27
27
  <div class="hero-faultbar__msg">{{ fault.message || fault.code }}</div>
28
28
  <div class="hero-faultbar__since">Since {{ faultSince }} · notified once</div>
29
29
  </div>
30
- <span class="hero-faultbar__badge">FAULT</span>
30
+ <v-btn v-if="caps?.liveVideo === true && online" variant="outlined" size="small" :loading="videoBusy" @click="openVideo">Live video</v-btn>
31
+ <v-btn v-if="canResolve" variant="outlined" size="small" :loading="resolving" @click="resolveFault">Mark resolved</v-btn>
32
+ <span class="hero-faultbar__badge">{{ faultBadge(fault) }}</span>
31
33
  </div>
32
34
 
33
35
  <RobotNoMonitoring v-if="noInterface" :brand-name="brand" :model="live?.model || undefined" />
@@ -39,6 +41,9 @@
39
41
  </div>
40
42
  </div>
41
43
  <div v-if="notReported" class="hero-notreported">{{ notReported }}</div>
44
+ <div v-if="caps?.liveVideo === true && online && !fault" class="hero-detail__video">
45
+ <v-btn variant="outlined" :loading="videoBusy" @click="openVideo">Live video</v-btn>
46
+ </div>
42
47
 
43
48
  <div v-if="commands.length">
44
49
  <div class="hero-section__title">Command bar</div>
@@ -97,6 +102,8 @@
97
102
  </div>
98
103
  </div>
99
104
 
105
+ <RobotPatrolHistory v-if="robotType === 'security' && caps?.patrolHistory === true" :robot-id="robotId" />
106
+
100
107
  <v-dialog v-model="editOpen" width="520" persistent>
101
108
  <RobotForm
102
109
  v-if="editOpen && record"
@@ -136,6 +143,7 @@ import RobotViewOnlyNotice from "./ViewOnlyNotice.vue";
136
143
  import RobotLivePlan from "./LivePlan.vue";
137
144
  import RobotToast from "./Toast.vue";
138
145
  import RobotForm from "./Form.vue";
146
+ import RobotPatrolHistory from "./PatrolHistory.vue";
139
147
  import useRobotLive, { siteStatesFeed } from "../../composables/useRobotLive";
140
148
  import {
141
149
  brandName,
@@ -143,6 +151,7 @@ import {
143
151
  cleaningScheduleRows,
144
152
  commandBody,
145
153
  commandLabel,
154
+ faultBadge,
146
155
  headingDeg,
147
156
  isMoving,
148
157
  mergePose,
@@ -188,8 +197,19 @@ const emit = defineEmits<{
188
197
  status: [status: { connected: boolean }];
189
198
  }>();
190
199
 
191
- const { getRobot, getRobotBrands, getRobotPlans, getPatrolRoutes, sendRobotCommand, trackRobot, deleteRobot, getRobotMaps, getRobotMapImages } =
192
- useRobots();
200
+ const {
201
+ getRobot,
202
+ getRobotBrands,
203
+ getRobotPlans,
204
+ getPatrolRoutes,
205
+ sendRobotCommand,
206
+ trackRobot,
207
+ deleteRobot,
208
+ getRobotMaps,
209
+ getRobotMapImages,
210
+ getLiveVideo,
211
+ resolveRobotAlerts,
212
+ } = useRobots();
193
213
 
194
214
  const record = ref<Record<string, any> | null>(null);
195
215
  const brands = ref<Array<Record<string, any>>>([]);
@@ -379,6 +399,42 @@ async function sendSpot() {
379
399
  }
380
400
  }
381
401
 
402
+ /* ---- security-robot extras: live video (minted per click, never kept), resolve an alarm ---- */
403
+ const videoBusy = ref(false);
404
+ async function openVideo() {
405
+ // Opened before the await so the browser treats it as the click's own window.
406
+ const win = typeof window !== "undefined" ? window.open("", "_blank") : null;
407
+ videoBusy.value = true;
408
+ try {
409
+ const { url } = await getLiveVideo(props.robotId);
410
+ if (!/^https?:\/\//i.test(url)) throw new Error("The robot's cloud sent a video link this page cannot open.");
411
+ if (win) {
412
+ win.opener = null;
413
+ win.location.href = url;
414
+ } else toast.value = "Allow pop-ups to watch the robot's live video.";
415
+ } catch (e) {
416
+ win?.close();
417
+ toast.value = robotErrorMessage(e, "Could not open the live video.");
418
+ } finally {
419
+ videoBusy.value = false;
420
+ }
421
+ }
422
+ const canResolve = computed(
423
+ () => props.canCommand && caps.value?.resolveAlerts === true && faultBadge(fault.value) === "ALARM" && typeof fault.value?.code === "string",
424
+ );
425
+ const resolving = ref(false);
426
+ async function resolveFault() {
427
+ resolving.value = true;
428
+ try {
429
+ const res = await resolveRobotAlerts(props.robotId, String(fault.value?.code ?? ""));
430
+ toast.value = res?.resolved ? `Marked resolved · ${res.resolved} alert${res.resolved === 1 ? "" : "s"}` : "Nothing open to resolve.";
431
+ } catch (e) {
432
+ toast.value = robotErrorMessage(e, "Could not mark the alert resolved.");
433
+ } finally {
434
+ resolving.value = false;
435
+ }
436
+ }
437
+
382
438
  /* ---- edit / remove ---- */
383
439
  function onSaved() {
384
440
  editOpen.value = false;
@@ -155,8 +155,8 @@ const found = ref<Array<{ value: string; label: string; disabled: boolean }>>([]
155
155
  const finding = ref(false);
156
156
  const labels = computed(() => credentialLabels(brand.value));
157
157
  const typed = computed(() => !!appKey.value.trim() && !!appToken.value.trim());
158
- // Ounitech: one AppID/Secret per company, so the robot is picked from its list.
159
- const discoverable = computed(() => brand.value === "ounitech");
158
+ // Ounitech / PadBot: one credential pair per company account, so the robot is picked from its list.
159
+ const discoverable = computed(() => brand.value === "ounitech" || brand.value === "padbot");
160
160
  const findHint = computed(() =>
161
161
  editing.value || typed.value
162
162
  ? "Press Find robots"
@@ -0,0 +1,100 @@
1
+ <template>
2
+ <!--
3
+ Patrol history (brands with `patrolHistory`, e.g. PadBot): the robot's past
4
+ patrols from its own cloud, newest first; a row opens what was captured at
5
+ each checkpoint. Read only; photo links open the brand's image.
6
+ -->
7
+ <div class="hero-patrolhist">
8
+ <div class="hero-aside__head">
9
+ <span class="hero-aside__title">Patrol history</span>
10
+ <span class="hero-aside__hint">route · result · time · distance · alerts</span>
11
+ </div>
12
+ <div v-if="error" class="hero-sched__empty">{{ error }}</div>
13
+ <div v-else-if="loaded && !rows.length" class="hero-sched__empty">No patrols recorded yet.</div>
14
+ <template v-for="r in rows" :key="r.key">
15
+ <button type="button" class="hero-sched__row hero-patrolhist__row" :aria-expanded="open === r.key" @click="toggle(r.key)">
16
+ <b>{{ r.when }}</b><span>{{ r.route }} · <span :class="`hero-patrolhist__${r.tone}`">{{ r.result }}</span></span><span>{{ r.detail }}</span>
17
+ </button>
18
+ <div v-if="open === r.key" class="hero-patrolhist__visits">
19
+ <div v-if="visitsLoading" class="hero-sched__empty">Loading checkpoints…</div>
20
+ <div v-else-if="!visits.length" class="hero-sched__empty">No checkpoint captures for this patrol.</div>
21
+ <div v-for="(v, i) in visits" :key="i" class="hero-patrolhist__visit">
22
+ <b>{{ v.checkpoint || "Checkpoint" }}</b>
23
+ <span>{{ v.skipped ? "Skipped" : activityWhen(v.at, now) }}</span>
24
+ <span v-if="v.centerTempC !== null">{{ v.centerTempC }}°C (max {{ v.maxTempC ?? "—" }}°C)</span>
25
+ <a v-if="safeUrl(v.imageUrl)" :href="safeUrl(v.imageUrl)" target="_blank" rel="noopener noreferrer">Photo</a>
26
+ <a v-if="safeUrl(v.thermalImageUrl)" :href="safeUrl(v.thermalImageUrl)" target="_blank" rel="noopener noreferrer">Thermal</a>
27
+ </div>
28
+ </div>
29
+ </template>
30
+ <div v-if="pages > 1" class="hero-patrolhist__foot">
31
+ <v-btn variant="text" size="small" :disabled="page <= 1" @click="load(page - 1)">Newer</v-btn>
32
+ <span>Page {{ page }} of {{ pages }}</span>
33
+ <v-btn variant="text" size="small" :disabled="page >= pages" @click="load(page + 1)">Older</v-btn>
34
+ </div>
35
+ </div>
36
+ </template>
37
+
38
+ <script setup lang="ts">
39
+ import { computed, ref, watch } from "vue";
40
+ import { activityWhen, patrolRecordRow, robotErrorMessage } from "../../utils/robot-display";
41
+
42
+ const props = defineProps<{ robotId: string }>();
43
+ const { getPatrolHistory, getPatrolVisits } = useRobots();
44
+
45
+ const items = ref<any[]>([]);
46
+ const page = ref(1);
47
+ const pages = ref(0);
48
+ const loaded = ref(false);
49
+ const error = ref("");
50
+ const open = ref("");
51
+ const visits = ref<any[]>([]);
52
+ const visitsLoading = ref(false);
53
+ const now = Date.now();
54
+ const rows = computed(() => items.value.map((r) => patrolRecordRow(r, now)));
55
+
56
+ /** Only http(s) links from the brand are rendered. */
57
+ const safeUrl = (u: unknown) => (typeof u === "string" && /^https?:\/\//i.test(u) ? u : "");
58
+
59
+ async function load(p = 1) {
60
+ error.value = "";
61
+ try {
62
+ const res = await getPatrolHistory(props.robotId, { page: p, limit: 10 });
63
+ items.value = res?.items ?? [];
64
+ pages.value = res?.pages ?? 0;
65
+ page.value = p;
66
+ } catch (e) {
67
+ error.value = robotErrorMessage(e, "Could not load the patrol history.");
68
+ } finally {
69
+ loaded.value = true;
70
+ }
71
+ }
72
+
73
+ async function toggle(key: string) {
74
+ if (open.value === key) return void (open.value = "");
75
+ open.value = key;
76
+ visits.value = [];
77
+ visitsLoading.value = true;
78
+ try {
79
+ visits.value = (await getPatrolVisits(props.robotId, key))?.items ?? [];
80
+ } catch {
81
+ visits.value = [];
82
+ } finally {
83
+ visitsLoading.value = false;
84
+ }
85
+ }
86
+
87
+ watch(() => props.robotId, () => load(1), { immediate: true });
88
+ </script>
89
+
90
+ <style scoped>
91
+ .hero-patrolhist { margin-top: 20px; }
92
+ .hero-patrolhist__row { width: 100%; text-align: left; background: none; border: 0; cursor: pointer; color: inherit; font: inherit; }
93
+ .hero-patrolhist__visits { padding: 4px 0 8px 16px; }
94
+ .hero-patrolhist__visit { display: flex; flex-wrap: wrap; gap: 12px; padding: 4px 0; font-size: 12px; }
95
+ .hero-patrolhist__visit a { color: inherit; text-decoration: underline; }
96
+ .hero-patrolhist__foot { display: flex; align-items: center; gap: 8px; font-size: 12px; margin-top: 6px; }
97
+ .hero-patrolhist__success { color: rgb(var(--v-theme-success)); }
98
+ .hero-patrolhist__warning { color: rgb(var(--v-theme-warning)); }
99
+ .hero-patrolhist__info { color: rgb(var(--v-theme-info)); }
100
+ </style>
@@ -273,7 +273,7 @@ export default function useRobots() {
273
273
  | { kind: "full"; cleanTimes?: number; modeFlag?: number; subModeFlag?: number; levelFlag?: number }
274
274
  | { kind: "area"; areas: string[]; cleanTimes?: number; modeFlag?: number; subModeFlag?: number; levelFlag?: number }
275
275
  | { kind: "spot"; points: Array<{ x: number; y: number }>; cleanTimes?: number; modeFlag?: number; subModeFlag?: number; levelFlag?: number }
276
- | { kind: "pause" | "resume" | "cancel" }
276
+ | { kind: "pause" | "resume" | "cancel" | "patrol-resume" }
277
277
  | { kind: "navigate"; x: number; y: number; theta?: number }
278
278
  | { kind: "relocalize"; floorNo: number; x: number; y: number; theta: number },
279
279
  ) {
@@ -283,6 +283,42 @@ export default function useRobots() {
283
283
  );
284
284
  }
285
285
 
286
+ /* ---------------------------------------- security-robot extras (brand capability-gated) */
287
+
288
+ /** `liveVideo`: the robot's camera URL, minted now (valid until `expiresAt`). Open it; never store it. */
289
+ function getLiveVideo(id: string) {
290
+ return useNuxtApp().$api<{ url: string; expiresAt: string | null }>(`/api/robots/${id}/live-video`, {
291
+ method: "POST",
292
+ query: { appSlug },
293
+ body: {},
294
+ });
295
+ }
296
+
297
+ /** `resolveAlerts`: mark the robot's open alerts (of one code, or all) resolved in the brand's system. */
298
+ function resolveRobotAlerts(id: string, code?: string) {
299
+ return useNuxtApp().$api<{ resolved: number }>(`/api/robots/${id}/alerts/resolve`, {
300
+ method: "POST",
301
+ query: { appSlug },
302
+ body: code ? { code } : {},
303
+ });
304
+ }
305
+
306
+ /** `patrolHistory`: past patrols, newest first. */
307
+ function getPatrolHistory(id: string, { page = 1, limit = 10 } = {}) {
308
+ return useNuxtApp().$api<{ items: any[]; total: number; pages: number }>(`/api/robots/${id}/patrol-history`, {
309
+ method: "GET",
310
+ query: { appSlug, page, limit },
311
+ });
312
+ }
313
+
314
+ /** `patrolHistory`: one past patrol's checkpoint visits. */
315
+ function getPatrolVisits(id: string, recordId: string, { page = 1, limit = 50 } = {}) {
316
+ return useNuxtApp().$api<{ items: any[]; total: number; pages: number }>(
317
+ `/api/robots/${id}/patrol-history/${encodeURIComponent(recordId)}`,
318
+ { method: "GET", query: { appSlug, page, limit } },
319
+ );
320
+ }
321
+
286
322
  /* ---------------------------------------- building maps and lifts */
287
323
 
288
324
  /**
@@ -519,5 +555,9 @@ export default function useRobots() {
519
555
  getRobotMapImages,
520
556
  getRobotPlans,
521
557
  sendRobotCommand,
558
+ getLiveVideo,
559
+ resolveRobotAlerts,
560
+ getPatrolHistory,
561
+ getPatrolVisits,
522
562
  };
523
563
  }
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.52.0",
5
+ "version": "4.53.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.",
@@ -11,6 +11,8 @@
11
11
  */
12
12
  export function credentialLabels(brand: unknown): { key: string; token: string } {
13
13
  if (brand === "ounitech") return { key: "AppID", token: "Secret" };
14
+ // PadBot issues an AccessKey ID + AccessKey Secret per account (API v4.0 §2.1).
15
+ if (brand === "padbot") return { key: "AccessKey ID", token: "AccessKey Secret" };
14
16
  if (brand === "demo") return { key: "App key (any value)", token: "Token (any value)" };
15
17
  return { key: "App key", token: "Token" };
16
18
  }
@@ -286,8 +286,10 @@ const COMMAND_NEEDS: Readonly<Record<string, string>> = {
286
286
  area: "startProcess",
287
287
  spot: "spotClean",
288
288
  return: "sendToDock",
289
- pause: "pauseProcess",
290
- resume: "pauseProcess",
289
+ // Pause / resume = pausing a robot mid-way; cancel = stopping the job. Split
290
+ // so a brand that can stop but not pause (PadBot) shows Cancel with no Pause.
291
+ pause: "pauseResume",
292
+ resume: "pauseResume",
291
293
  cancel: "pauseProcess",
292
294
  navigate: "navigateToPoint",
293
295
  relocalize: "relocalize",
@@ -383,7 +385,8 @@ export type TRobotCommandKind =
383
385
  | "resume"
384
386
  | "cancel"
385
387
  | "navigate"
386
- | "relocalize";
388
+ | "relocalize"
389
+ | "patrol-resume";
387
390
 
388
391
  /**
389
392
  * The command bar's buttons, in bar order; each needs `COMMAND_NEEDS[kind]`.
@@ -398,6 +401,7 @@ const COMMANDS: ReadonlyArray<{ kind: TRobotCommandKind; label: string }> = [
398
401
  { kind: "cancel", label: "Cancel" },
399
402
  { kind: "navigate", label: "Go to point" },
400
403
  { kind: "relocalize", label: "Set position" },
404
+ { kind: "patrol-resume", label: "Resume patrol" },
401
405
  ];
402
406
 
403
407
  export type TRobotCommand = { kind: TRobotCommandKind; label: string; primary: boolean };
@@ -409,7 +413,9 @@ export type TRobotCommand = { kind: TRobotCommandKind; label: string; primary: b
409
413
  * - role: only a Manager (`canCommand`, i.e. the app's command permission);
410
414
  * - online: only a robot confirmed online (`online === true`);
411
415
  * - context: Pause only while working, Resume only while paused;
412
- * - type: a security robot has no cleaning jobs, so no Clean all / Clean area.
416
+ * - type: a security robot has no cleaning jobs, so no Clean all / Clean area -
417
+ * but a brand with `patrolControl` gets Start patrol (the default route) and
418
+ * Resume patrol (while it is not already patrolling).
413
419
  *
414
420
  * Returns [] whenever the bar should not render at all.
415
421
  */
@@ -422,13 +428,20 @@ export function visibleCommands(input: {
422
428
  } | null | undefined): TRobotCommand[] {
423
429
  if (!input || input.canCommand !== true || input.online !== true) return [];
424
430
  const caps = input.capabilities ?? {};
431
+ const patrol = input.type === "security" && caps.patrolControl === true;
425
432
  return COMMANDS.filter((c) => {
433
+ if (c.kind === "patrol-resume") return patrol && input.mode !== "WORKING" && input.mode !== "IN_TRANSIT";
426
434
  if (caps[COMMAND_NEEDS[c.kind]!] !== true) return false;
427
435
  if (c.kind === "pause" && input.mode !== "WORKING") return false;
428
436
  if (c.kind === "resume" && input.mode !== "PAUSED") return false;
429
- if ((c.kind === "full" || c.kind === "area") && input.type === "security") return false;
437
+ if (c.kind === "full" && input.type === "security") return patrol;
438
+ if (c.kind === "area" && input.type === "security") return false;
430
439
  return true;
431
- }).map((c) => ({ kind: c.kind, label: c.label, primary: c.kind === "full" }));
440
+ }).map((c) => ({
441
+ kind: c.kind,
442
+ label: c.kind === "full" && input.type === "security" ? "Start patrol" : c.label,
443
+ primary: c.kind === "full",
444
+ }));
432
445
  }
433
446
 
434
447
  /**
@@ -947,6 +960,7 @@ export function optimisticMode(kind: unknown, mode: unknown): string {
947
960
  case "area":
948
961
  case "spot":
949
962
  case "resume":
963
+ case "patrol-resume":
950
964
  return "WORKING";
951
965
  case "return":
952
966
  return "CHARGING_CYCLE";
@@ -1184,6 +1198,28 @@ export function activityRow(entry: Record<string, any> | null | undefined, now:
1184
1198
  * type; option shows capability"): only brands that make that class (a brand
1185
1199
  * declaring no `types` makes cleaning robots), as "Ounitech · Basic control".
1186
1200
  */
1201
+ /** "Open alarm" / "Machine fault" badge for the fault bar: a security robot's alarm is something it SAW. */
1202
+ export function faultBadge(fault: { code?: unknown; alarm?: unknown } | null | undefined): "ALARM" | "FAULT" {
1203
+ return fault?.alarm === true || (typeof fault?.code === "string" && fault.code.startsWith("ALARM-")) ? "ALARM" : "FAULT";
1204
+ }
1205
+
1206
+ /** One past patrol as a row: "Completed · 1 min 41 s · 19.9 m · 1 alert". */
1207
+ export function patrolRecordRow(r: Record<string, any> | null | undefined, now: number = Date.now()) {
1208
+ const RESULT: Record<string, string> = { completed: "Completed", partial: "Partly completed", failed: "Failed", "in-progress": "In progress" };
1209
+ const secs = typeof r?.durationS === "number" && Number.isFinite(r.durationS) ? Math.max(0, Math.round(r.durationS)) : null;
1210
+ const dur = secs === null ? "—" : secs >= 60 ? `${Math.floor(secs / 60)} min ${secs % 60} s` : `${secs} s`;
1211
+ const dist = typeof r?.distanceM === "number" && Number.isFinite(r.distanceM) ? `${Math.round(r.distanceM * 10) / 10} m` : "—";
1212
+ const alerts = Number(r?.alarmCount) || 0;
1213
+ return {
1214
+ key: String(r?.id ?? ""),
1215
+ when: activityWhen(r?.startedAt, now),
1216
+ route: String(r?.routeName || "Patrol"),
1217
+ result: RESULT[String(r?.result)] ?? "Unknown",
1218
+ tone: (r?.result === "completed" ? "success" : r?.result === "in-progress" ? "info" : "warning") as THeroTone,
1219
+ detail: `${dur} · ${dist} · ${alerts === 1 ? "1 alert" : `${alerts} alerts`}`,
1220
+ };
1221
+ }
1222
+
1187
1223
  export function brandOptions(
1188
1224
  brands: Array<Record<string, any>> | null | undefined,
1189
1225
  type: unknown,