@7365admin1/layer-common 4.35.1-staging.410 → 4.35.1-staging.412

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.
@@ -801,6 +801,34 @@
801
801
  margin-bottom: 8px;
802
802
  }
803
803
  .hero-detail__grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 380px), 1fr)); gap: 24px; }
804
+
805
+ /* Robot detail layout (2026-09-26): strip in one row, camera | map above the fold, stacked when narrow. */
806
+ .hero-strip { grid-template-columns: repeat(auto-fit, minmax(128px, 1fr)); }
807
+ .hero-strip .hero-fact { padding: 8px 12px; }
808
+ .hero-strip .hero-fact__value { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
809
+ .hero-detail__main { display: grid; grid-template-columns: minmax(0, 11fr) minmax(0, 9fr); gap: 20px; align-items: start; }
810
+ .hero-detail__main--single { grid-template-columns: minmax(0, 1fr); }
811
+ .hero-detail__cam, .hero-detail__map { display: flex; flex-direction: column; gap: 10px; min-width: 0; }
812
+ .hero-detail__top { scroll-margin-top: 72px; }
813
+ .hero-detail__main { --hero-plan-max: 280px; }
814
+ .hero-detail__main--single { --hero-plan-max: 360px; }
815
+ .hero-detail__main .hero-plan--detail { max-height: var(--hero-plan-max); }
816
+ .hero-detail__main .hero-livepanel .hero-dialog__actions:empty { display: none; }
817
+ .hero-detail__commands { border-top: 2px solid var(--hero-divider); padding-top: 10px; }
818
+ .hero-detail__alertcode { color: var(--hero-accent); }
819
+ @media (max-width: 900px) { .hero-detail__main { grid-template-columns: minmax(0, 1fr); } }
820
+ .hero-sched__note { font-size: 12px; opacity: 0.65; margin-bottom: 4px; }
821
+ .hero-sched__row.hero-sched__row--slot { grid-template-columns: minmax(110px, 160px) minmax(0, 1fr); }
822
+ .hero-sched__row.hero-sched__row--alert { grid-template-columns: 110px minmax(0, 1fr) auto; }
823
+ .hero-detail__sub { display: flex; flex-wrap: wrap; gap: 4px 14px; opacity: 0.65; font-size: 12px; margin-top: 2px; }
824
+ .hero-detail__title--main { flex-wrap: nowrap; min-width: 0; }
825
+ .hero-detail__title--main h2 { overflow: hidden; text-overflow: ellipsis; min-width: 8ch; }
826
+ @media (max-width: 700px) { .hero-detail__title--main { flex-wrap: wrap; } .hero-detail__title--main h2 { flex-basis: 100%; } }
827
+ @media (max-width: 560px) { .hero-faultbar { grid-auto-flow: row; } .hero-faultbar > .v-btn, .hero-faultbar__badge { justify-self: start; } }
828
+ .hero-detail__main .hero-mapsel { margin: 0; }
829
+ .hero-can--fold { padding: 8px 14px; }
830
+ .hero-can--fold > summary { cursor: pointer; list-style: revert; }
831
+ .hero-can--fold[open] > summary { margin-bottom: 6px; }
804
832
  .hero-detail__grid .hero-aside__hint { opacity: 0.55; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; min-width: 0; }
805
833
 
806
834
  .hero-sched__empty { padding: 20px 0; opacity: 0.65; }
@@ -1,8 +1,16 @@
1
1
  <template>
2
- <!-- Robot detail (README §3). -->
3
- <div>
2
+ <!--
3
+ Robot detail (README §3), laid out for the operator's job (reasoning:
4
+ Project-Notes 2026-09-26-robot-detail-layout.md):
5
+ header (who / where / model) -> one-row status strip -> ABOVE THE FOLD the
6
+ camera (left, large) and the live map (right) with the commands that act on
7
+ them directly under the map -> patrol / cleaning: schedule next to recent
8
+ runs -> this robot's alerts -> "What this robot can do" folded at the bottom.
9
+ No camera: the map takes the full width. Narrow: camera, map, commands, patrol.
10
+ -->
11
+ <div ref="topEl" class="hero-detail__top">
4
12
  <button type="button" class="hero-back hero-back--sm" @click="emit('back')">‹ {{ backLabel || `${record?.site?.name || "Site"} fleet` }}</button>
5
- <div class="hero-detail__title">
13
+ <div class="hero-detail__title hero-detail__title--main">
6
14
  <h2>{{ record?.name ?? "Robot" }}</h2>
7
15
  <template v-if="statusMode">
8
16
  <RobotStatusChip :mode="statusMode" :type="robotType" />
@@ -10,13 +18,16 @@
10
18
  <span v-else-if="live && !live.ok" class="hero-tag hero-tag--neutral">Reconnecting</span>
11
19
  <span v-if="caps" class="hero-tag hero-tag--neutral">{{ capabilityLabel(caps) }}</span>
12
20
  <span class="hero-tag hero-tag--neutral">{{ typeLabel(robotType) }}</span>
13
- <span v-if="record" class="hero-detail__where">{{ record.client?.name }} · {{ record.site?.name }}</span>
14
21
  <span class="hero-head__spacer" />
15
22
  <template v-if="record">
16
23
  <v-btn v-if="canEdit" variant="outlined" @click="editOpen = true">Edit</v-btn>
17
24
  <v-btn v-if="canDelete" variant="text" class="hero-ghost" @click="removeOpen = true">Remove</v-btn>
18
25
  </template>
19
26
  </div>
27
+ <div v-if="record" class="hero-detail__sub">
28
+ <span>{{ record.client?.name }} · {{ record.site?.name }}</span>
29
+ <span v-if="identity">{{ identity }}</span>
30
+ </div>
20
31
  </div>
21
32
 
22
33
  <div v-if="error" class="hero-notice" role="alert">{{ error }}</div>
@@ -31,49 +42,30 @@
31
42
  <template v-for="ph in faultPhotos(fault)" :key="ph.label"> · <a :href="ph.url" target="_blank" rel="noopener noreferrer">{{ ph.label }}</a></template>
32
43
  </div>
33
44
  </div>
34
- <v-btn v-if="caps?.liveVideo === true && online" variant="outlined" size="small" @click="livePanel?.show()">Live view</v-btn>
35
45
  <v-btn v-if="canResolve" variant="outlined" size="small" :loading="resolving" @click="resolveFault">Mark resolved</v-btn>
36
46
  <span class="hero-faultbar__badge">{{ faultBadge(fault) }}</span>
37
47
  </div>
38
48
 
39
49
  <RobotNoMonitoring v-if="noInterface" :brand-name="brand" :model="live?.model || undefined" />
40
50
 
41
- <!-- What THIS robot (brand + model) can do, in our words; nothing it can't. -->
42
- <section v-if="abilities.length" class="hero-can" aria-label="What this robot can do">
43
- <div class="hero-can__title">What this robot can do</div>
44
- <div v-for="r in abilities" :key="r.label" class="hero-can__row">
45
- <span class="hero-can__label">{{ r.label }}</span>
46
- <span>{{ r.value }}</span>
51
+ <!-- Status strip: connection · battery · what it is doing · floor · last seen, one row. -->
52
+ <div v-if="stripFacts.length" class="hero-facts hero-strip" role="list" aria-label="Robot status">
53
+ <div v-for="f in stripFacts" :key="f.label" class="hero-fact" role="listitem">
54
+ <div class="hero-fact__label">{{ f.label }}</div>
55
+ <div class="hero-fact__value" :class="{ 'hero-fact__value--dim': f.dim }" :title="String(f.value ?? '')">{{ f.value }}</div>
47
56
  </div>
48
- </section>
49
-
50
- <div v-if="commands.length">
51
- <div class="hero-section__title">Commands</div>
52
- <RobotCommandBar
53
- :capabilities="caps"
54
- :can-command="canCommand"
55
- :online="live?.ok === true && live.online === true"
56
- :mode="statusMode"
57
- :type="robotType"
58
- :busy="busy"
59
- @command="send"
60
- />
61
57
  </div>
62
- <RobotViewOnlyNotice v-if="!canCommand && !noInterface" />
63
-
64
- <div>
65
- <div class="hero-section__title">Status</div>
66
- <div class="hero-facts" role="list" aria-label="Robot status">
67
- <div v-for="f in statusFacts" :key="f.label" class="hero-fact" role="listitem">
68
- <div class="hero-fact__label">{{ f.label }}</div>
69
- <div class="hero-fact__value" :class="{ 'hero-fact__value--dim': f.dim }">{{ f.value }}</div>
58
+
59
+ <!-- Above the fold: camera | map, with the commands under the map they act on. -->
60
+ <div v-if="!noInterface" class="hero-detail__main" :class="{ 'hero-detail__main--single': !hasCamera }">
61
+ <section v-if="hasCamera" class="hero-detail__cam" aria-label="Camera">
62
+ <RobotLiveView v-if="caps?.liveVideo === true" ref="livePanel" panel :robot-id="robotId" :name="record?.name" :offline="!online" />
63
+ <div v-if="canDrive" class="hero-detail__video">
64
+ <v-btn variant="outlined" @click="driveOpen = true">Manual control</v-btn>
70
65
  </div>
71
- </div>
72
- </div>
66
+ </section>
73
67
 
74
- <div v-if="!noInterface" class="hero-detail__grid">
75
- <div>
76
- <div class="hero-section__title">Location</div>
68
+ <section class="hero-detail__map" aria-label="Map">
77
69
  <div v-if="gapText" class="hero-notice" role="status">{{ gapText }}</div>
78
70
  <div v-if="locationFacts.length" class="hero-facts hero-facts--tight" role="list" aria-label="Robot location">
79
71
  <div v-for="f in locationFacts" :key="f.label" class="hero-fact" role="listitem">
@@ -92,7 +84,6 @@
92
84
  </select>
93
85
  <span v-if="selectedMap?.current" class="hero-mapsel__here">Robot is here</span>
94
86
  <span v-else-if="selectedMap" class="hero-mapsel__hint">{{ hereText }}</span>
95
- <span v-if="selectedMap?.routeNames.length" class="hero-mapsel__hint">Routes: {{ selectedMap.routeNames.join(", ") }}</span>
96
87
  </div>
97
88
  <RobotLivePlan
98
89
  :name="record?.name"
@@ -122,43 +113,71 @@
122
113
  </div>
123
114
  </template>
124
115
  <div v-else-if="caps" class="hero-sched__empty">{{ brand }} doesn't share where this robot is with us yet.</div>
125
- </div>
126
116
 
127
- <div>
128
- <div class="hero-section__title">{{ robotType === "security" ? "Patrol" : "Cleaning" }}</div>
129
- <div class="hero-aside__head">
130
- <span class="hero-aside__title">{{ robotType === "security" ? "Patrol schedule" : "Cleaning schedule" }}</span>
131
- <span class="hero-aside__hint">{{
132
- robotType === "security" ? "days · start–end · floor · checkpoints × rounds" : "days · start–end · jobs per floor · passes"
133
- }}</span>
117
+ <div v-if="commands.length" class="hero-detail__commands">
118
+ <RobotCommandBar
119
+ :capabilities="caps"
120
+ :can-command="canCommand"
121
+ :online="live?.ok === true && live.online === true"
122
+ :mode="statusMode"
123
+ :type="robotType"
124
+ :busy="busy"
125
+ @command="send"
126
+ />
134
127
  </div>
135
- <template v-if="!schedule.length && robotType === 'security' && patrol?.scheduleShared === false">
136
- <div class="hero-sched__empty">{{ scheduleElsewhere }}</div>
137
- <!-- The runs themselves are in Patrol history below (said once). -->
138
- <div v-if="runHint" class="hero-sched__hint">{{ runHint }} - a guess from recent runs, not the schedule.</div>
128
+ <RobotViewOnlyNotice v-if="!canCommand" />
129
+ </section>
130
+ </div>
131
+
132
+ <!-- Patrol / cleaning: when it runs, next to what it did. -->
133
+ <div v-if="!noInterface" class="hero-detail__grid">
134
+ <div>
135
+ <div class="hero-section__title">{{ robotType === "security" ? "Patrol schedule" : "Cleaning schedule" }}</div>
136
+ <template v-if="robotType === 'security' && !schedule.length && patrol?.scheduleShared === false">
137
+ <div class="hero-sched__note">Detected from the robot's recent patrols (last 30 days). Schedules are changed in {{ makerApp }}.</div>
138
+ <div v-if="!runsLoaded" class="hero-sched__empty">Checking recent patrols…</div>
139
+ <div v-else-if="!detected.length" class="hero-sched__empty">No regular patrols detected in the last 30 days.</div>
140
+ <div v-for="s in detected" :key="s.route + s.minutes" class="hero-sched__row hero-sched__row--slot">
141
+ <b>{{ s.route }}</b><span>{{ patrolSlotText(s, now) }}</span>
142
+ </div>
143
+ </template>
144
+ <template v-else>
145
+ <div class="hero-sched__note">{{ robotType === "security" ? "days · start–end · floor · checkpoints × rounds" : "days · start–end · jobs per floor · passes" }}</div>
146
+ <div v-if="!schedule.length" class="hero-sched__empty">No schedule yet.</div>
147
+ <div v-for="p in schedule" :key="p.key" class="hero-sched__row">
148
+ <b>{{ p.days }}</b><span>{{ p.time }}</span><span>{{ p.jobs }}</span>
149
+ </div>
139
150
  </template>
140
- <div v-else-if="!schedule.length" class="hero-sched__empty">No schedule yet.</div>
141
- <div v-for="p in schedule" :key="p.key" class="hero-sched__row">
142
- <b>{{ p.days }}</b><span>{{ p.time }}</span><span>{{ p.jobs }}</span>
143
- </div>
144
151
  <template v-if="unplacedRoutes.length">
145
- <div class="hero-aside__hint">Patrol routes (stops not placed on the plan)</div>
152
+ <div class="hero-sched__note">Patrol routes (stops not placed on the map)</div>
146
153
  <div v-for="r in unplacedRoutes" :key="r.id" class="hero-sched__row">
147
154
  <b>{{ r.name }}</b><span>Stops: {{ r.text }}</span>
148
155
  </div>
149
156
  </template>
150
157
  </div>
158
+ <div v-if="robotType === 'security' && caps?.patrolHistory === true">
159
+ <div class="hero-section__title">Recent patrols</div>
160
+ <RobotPatrolHistory :robot-id="robotId" compact />
161
+ </div>
151
162
  </div>
152
163
 
153
- <RobotPatrolHistory v-if="robotType === 'security' && caps?.patrolHistory === true" :robot-id="robotId" />
164
+ <!-- This robot's open alerts (the newest also sits in the red bar on top while it is open). -->
165
+ <section v-if="!noInterface && record" aria-label="Alerts for this robot">
166
+ <div class="hero-section__title">Alerts</div>
167
+ <div v-if="!robotAlerts.length" class="hero-sched__empty">No open alerts for this robot.</div>
168
+ <div v-for="a in robotAlerts" :key="a.key" class="hero-sched__row hero-sched__row--alert">
169
+ <b class="hero-detail__alertcode">{{ a.code }}</b><span>{{ a.message }}</span><span>{{ a.since }}</span>
170
+ </div>
171
+ </section>
154
172
 
155
- <div v-if="caps?.liveVideo === true || canDrive">
156
- <div class="hero-section__title">Camera</div>
157
- <RobotLiveView v-if="caps?.liveVideo === true" ref="livePanel" panel :robot-id="robotId" :name="record?.name" :offline="!online" />
158
- <div v-if="canDrive" class="hero-detail__video">
159
- <v-btn variant="outlined" @click="driveOpen = true">Manual control</v-btn>
173
+ <!-- What THIS robot (brand + model) can do, in our words; folded so it never pushes the camera down. -->
174
+ <details v-if="abilities.length" class="hero-can hero-can--fold">
175
+ <summary class="hero-can__title">What this robot can do</summary>
176
+ <div v-for="r in abilities" :key="r.label" class="hero-can__row">
177
+ <span class="hero-can__label">{{ r.label }}</span>
178
+ <span>{{ r.value }}</span>
160
179
  </div>
161
- </div>
180
+ </details>
162
181
 
163
182
  <v-dialog v-model="confirmOpen" width="440">
164
183
  <div v-if="confirming" class="hero-dialog" role="alertdialog" aria-labelledby="hero-confirm-title">
@@ -234,6 +253,7 @@ import { faultPhotos } from "../../utils/robot-control";
234
253
  import { robotMapDefault, robotMapViews } from "../../utils/robot-maps";
235
254
  import useRobotLive, { siteStatesFeed } from "../../composables/useRobotLive";
236
255
  import {
256
+ attentionText,
237
257
  brandName,
238
258
  capabilityLabel,
239
259
  cleaningScheduleRows,
@@ -246,7 +266,8 @@ import {
246
266
  lastKnownLabel,
247
267
  mergePose,
248
268
  optimisticMode,
249
- patrolRunHint,
269
+ detectPatrolSchedule,
270
+ patrolSlotText,
250
271
  patrolScheduleRows,
251
272
  pointCommandBody,
252
273
  savedPointGroups,
@@ -259,7 +280,6 @@ import {
259
280
  robotErrorMessage,
260
281
  robotFacts,
261
282
  robotStatus,
262
- scheduleElsewhereText,
263
283
  showSpotClean,
264
284
  sinceText,
265
285
  speedText,
@@ -308,9 +328,11 @@ const {
308
328
  getRobotMapImages,
309
329
  resolveRobotAlerts,
310
330
  getPatrolHistory,
331
+ getSiteFaults,
311
332
  } = useRobots();
312
333
 
313
334
  const record = ref<Record<string, any> | null>(null);
335
+ const topEl = ref<HTMLElement | null>(null);
314
336
  const brands = ref<Array<Record<string, any>>>([]);
315
337
  const error = ref("");
316
338
  const toast = ref("");
@@ -363,14 +385,25 @@ const faultSince = computed(() => sinceText(fault.value?.since, now.value));
363
385
 
364
386
  // Only what THIS robot reports (hide, never grey); what its maker keeps back is said once, in the card.
365
387
  const facts = computed(() => robotFacts({ record: record.value, live: live.value as any, now: now.value, hideUnreported: true }));
366
- const LOCATION = new Set(["Floor", "Live position"]);
367
- const statusFacts = computed(() => facts.value.filter((f) => !LOCATION.has(f.label)));
388
+ // The strip: connection · battery · what it is doing · floor · last seen (model and serial sit in the header).
389
+ const STRIP_ORDER = ["Connection", "Battery", "Robot reports", "Floor", "Last seen"];
390
+ const stripFacts = computed(() =>
391
+ STRIP_ORDER.map((l) => facts.value.find((f) => f.label === l))
392
+ .filter((f): f is NonNullable<typeof f> => !!f)
393
+ .map((f) => (f.label === "Robot reports" ? { ...f, label: "Doing now" } : f)),
394
+ );
395
+ const identity = computed(() => {
396
+ const model = facts.value.find((f) => f.label === "Model")?.value;
397
+ const serial = facts.value.find((f) => f.label === "Serial")?.value;
398
+ return [model, serial ? `Serial ${serial}` : ""].filter(Boolean).join(" · ");
399
+ });
368
400
  // The plan's readout already shows the live position; the cell only when there is no plan.
369
- const locationFacts = computed(() => facts.value.filter((f) => LOCATION.has(f.label) && !(f.label === "Live position" && showPlan.value)));
401
+ // Floor is in the strip; the plan's readout shows the live position, so the cell only when there is no plan.
402
+ const locationFacts = computed(() => facts.value.filter((f) => f.label === "Live position" && !showPlan.value));
370
403
  const abilities = computed(() =>
371
404
  robotAbilities({ capabilities: caps.value, profile: live.value?.ok ? live.value.profile : null, type: robotType.value, brand: brand.value }),
372
405
  );
373
- const scheduleElsewhere = computed(() => scheduleElsewhereText(robotType.value, brand.value, live.value?.ok ? live.value.profile : null));
406
+ const makerApp = computed(() => (live.value?.ok ? live.value.profile?.makerApp : "") || `${brand.value}'s own app`);
374
407
 
375
408
  const online = computed(() => live.value?.ok === true && live.value.online === true);
376
409
  const commands = computed(() =>
@@ -408,7 +441,7 @@ const spotOn = computed(() => showSpotClean({ canCommand: props.canCommand, capa
408
441
  const route = computed<TPatrolRouteView | null>(() =>
409
442
  selectedMap.value ? mapRoutes.value[0] ?? null : patrol.value?.routes?.find((r) => r.checkpoints?.length) ?? null,
410
443
  );
411
- const floorText = computed(() => locationFacts.value.find((f) => f.label === "Floor")?.value ?? "");
444
+ const floorText = computed(() => facts.value.find((f) => f.label === "Floor")?.value ?? "");
412
445
  // The plan only for a robot that reports where it is, has maps to show, takes spot clicks, or walks a route we can draw.
413
446
  const showPlan = computed(
414
447
  () => !caps.value || reportsPosition.value || spotOn.value || mapViews.value.length > 0 || !!route.value?.checkpoints?.length,
@@ -433,17 +466,34 @@ const schedule = computed(() => (robotType.value === "security" ? patrolSchedule
433
466
  const unplacedRoutes = computed(() =>
434
467
  (patrol.value?.routes ?? []).map((r) => ({ id: r.id, name: r.name, text: unplacedStopsText(r) })).filter((r) => r.text),
435
468
  );
436
- // The brand keeps its schedules to itself: recent runs (and a labelled guess) instead.
469
+ // The brand keeps its schedules to itself (PadBot): detect them from the last 30 days of runs.
437
470
  const runs = ref<any[]>([]);
438
- const runHint = computed(() => patrolRunHint(runs.value, now.value));
471
+ const runsLoaded = ref(false);
472
+ const detected = computed(() => detectPatrolSchedule(runs.value, now.value));
473
+ /** Newest first, 100 a page, until a page reaches 30 days back. ponytail: capped at 5 pages (500 runs); raise if a robot patrols more than ~16x a day. */
474
+ async function loadRuns() {
475
+ const since = Date.now() - 30 * 86_400_000;
476
+ const all: any[] = [];
477
+ for (let page = 1; page <= 5; page++) {
478
+ const res = await getPatrolHistory(props.robotId, { page, limit: 100 }).catch(() => null);
479
+ const items = res?.items ?? [];
480
+ all.push(...items);
481
+ const oldest = Date.parse(items[items.length - 1]?.startedAt ?? "");
482
+ if (!items.length || page >= (res?.pages ?? 0) || !(oldest >= since)) break;
483
+ }
484
+ runs.value = all;
485
+ runsLoaded.value = true;
486
+ }
439
487
 
488
+ let runsLoading = false;
440
489
  async function loadSchedule() {
441
490
  if (!record.value || noInterface.value) return;
442
491
  try {
443
492
  if (robotType.value === "security") {
444
493
  patrol.value = await getPatrolRoutes(props.robotId);
445
- if (patrol.value?.scheduleShared === false && caps.value?.patrolHistory === true && !runs.value.length) {
446
- runs.value = (await getPatrolHistory(props.robotId, { page: 1, limit: 20 }).catch(() => null))?.items ?? [];
494
+ if (patrol.value?.scheduleShared === false && caps.value?.patrolHistory === true && !runsLoaded.value && !runsLoading) {
495
+ runsLoading = true;
496
+ loadRuns().finally(() => (runsLoading = false));
447
497
  }
448
498
  } else plans.value = (await getRobotPlans(props.robotId))?.items ?? [];
449
499
  } catch {
@@ -613,6 +663,7 @@ function onPick(p: TSavedPoint) {
613
663
 
614
664
  /* ---- security-robot extras: live view (minted per open, never kept), manual control, resolve an alarm ---- */
615
665
  const livePanel = ref<{ show(): void } | null>(null);
666
+ const hasCamera = computed(() => caps.value?.liveVideo === true || canDrive.value);
616
667
  const driveOpen = ref(false);
617
668
  const canDrive = computed(() => props.canCommand && caps.value?.manualDrive === true && online.value);
618
669
  const canResolve = computed(
@@ -631,6 +682,23 @@ async function resolveFault() {
631
682
  }
632
683
  }
633
684
 
685
+ /* ---- this robot's open alerts: the site's alert list, narrowed to this robot ---- */
686
+ const siteAlerts = ref<any[]>([]);
687
+ const robotAlerts = computed(() =>
688
+ siteAlerts.value
689
+ .filter((a) => String(a?.robot?._id ?? "") === props.robotId)
690
+ .map((a, i) => ({ key: `${a?.code ?? ""}-${i}`, ...attentionText(a), since: a?.since ? `Since ${sinceText(a.since, now.value)}` : "" })),
691
+ );
692
+ async function loadAlerts() {
693
+ const site = String(record.value?.site?._id ?? "");
694
+ if (!site) return;
695
+ try {
696
+ siteAlerts.value = (await getSiteFaults(site, { page: 1, limit: 100 }))?.items ?? [];
697
+ } catch {
698
+ siteAlerts.value = [];
699
+ }
700
+ }
701
+
634
702
  /* ---- edit / remove ---- */
635
703
  function onSaved() {
636
704
  editOpen.value = false;
@@ -674,9 +742,15 @@ watch(
674
742
  lastPose.value = null;
675
743
  prevPose.value = nextPose.value = null;
676
744
  streamLost.value = false;
745
+ runs.value = [];
746
+ runsLoaded.value = false;
747
+ siteAlerts.value = [];
677
748
  await loadRecord();
749
+ // Open with the robot's header at the top, so camera + map are on screen (the Super Admin page has pickers above).
750
+ topEl.value?.scrollIntoView?.({ block: "start" });
678
751
  loadSchedule();
679
752
  loadMaps();
753
+ loadAlerts();
680
754
  },
681
755
  { immediate: true },
682
756
  );
@@ -696,6 +770,7 @@ const timer = setInterval(() => {
696
770
  now.value = Date.now();
697
771
  if (typeof document !== "undefined" && document.visibilityState === "hidden") return;
698
772
  if (++ticks % 8 === 0 && robotType.value === "security") loadSchedule();
773
+ if (ticks % 30 === 0) loadAlerts();
699
774
  }, 1000);
700
775
  onBeforeUnmount(() => {
701
776
  clearInterval(timer);
@@ -35,11 +35,11 @@ const props = defineProps<{
35
35
  /** That image's width / height, so the plan keeps its shape. */
36
36
  aspect?: number | null;
37
37
  }>();
38
- /** The image's own shape; the detail plan's 440 px height cap narrows the width instead of squashing it. */
38
+ /** The image's own shape; the detail plan's height cap (--hero-plan-max, 440 px) narrows the width instead of squashing it. */
39
39
  const frameStyle = computed(() => {
40
40
  const a = props.image && props.aspect && Number.isFinite(props.aspect) && props.aspect > 0 ? props.aspect : null;
41
41
  if (!a) return undefined;
42
- return { aspectRatio: String(a), ...(props.detail ? { width: `min(100%, ${Math.round(440 * a)}px)`, maxHeight: "none", marginInline: "auto" } : {}) };
42
+ return { aspectRatio: String(a), ...(props.detail ? { width: `min(100%, calc(var(--hero-plan-max, 440px) * ${a.toFixed(4)}))`, maxHeight: "none", marginInline: "auto" } : {}) };
43
43
  });
44
44
  const emit = defineEmits<{ pick: [point: { x: number; y: number }] }>();
45
45
  const root = ref<HTMLElement | null>(null);
@@ -4,14 +4,14 @@
4
4
  patrols from its own cloud, newest first; a row opens what was captured at
5
5
  each checkpoint. Read only; photo links open the brand's image.
6
6
  -->
7
- <div class="hero-patrolhist">
8
- <div class="hero-aside__head">
7
+ <div class="hero-patrolhist" :class="{ 'hero-patrolhist--compact': compact }">
8
+ <div v-if="!compact" class="hero-aside__head">
9
9
  <span class="hero-aside__title">Patrol history</span>
10
10
  <span class="hero-aside__hint">route · result · time · distance · alerts</span>
11
11
  </div>
12
12
  <div v-if="error" class="hero-sched__empty">{{ error }}</div>
13
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">
14
+ <template v-for="r in shownRows" :key="r.key">
15
15
  <button type="button" class="hero-sched__row hero-patrolhist__row" :aria-expanded="open === r.key" @click="toggle(r.key)">
16
16
  <b>{{ r.when }}</b><span>{{ r.route }} · <span :class="`hero-patrolhist__${r.tone}`">{{ r.result }}</span></span><span>{{ r.detail }}</span>
17
17
  </button>
@@ -27,7 +27,11 @@
27
27
  </div>
28
28
  </div>
29
29
  </template>
30
- <div v-if="pages > 1" class="hero-patrolhist__foot">
30
+ <div v-if="short && (rows.length > 5 || pages > 1)" class="hero-patrolhist__foot">
31
+ <v-btn variant="text" size="small" @click="all = true">See all</v-btn>
32
+ </div>
33
+ <div v-else-if="pages > 1" class="hero-patrolhist__foot">
34
+ <v-btn v-if="compact" variant="text" size="small" @click="(all = false), load(1)">Show fewer</v-btn>
31
35
  <v-btn variant="text" size="small" :disabled="page <= 1" @click="load(page - 1)">Newer</v-btn>
32
36
  <span>Page {{ page }} of {{ pages }}</span>
33
37
  <v-btn variant="text" size="small" :disabled="page >= pages" @click="load(page + 1)">Older</v-btn>
@@ -39,7 +43,10 @@
39
43
  import { computed, ref, watch } from "vue";
40
44
  import { activityWhen, patrolRecordRow, robotErrorMessage } from "../../utils/robot-display";
41
45
 
42
- const props = defineProps<{ robotId: string }>();
46
+ /** `compact`: the newest 5 with "See all" (robot detail); the full pager after that. */
47
+ const props = defineProps<{ robotId: string; compact?: boolean }>();
48
+ const all = ref(false);
49
+ const short = computed(() => props.compact && !all.value);
43
50
  const { getPatrolHistory, getPatrolVisits } = useRobots();
44
51
 
45
52
  const items = ref<any[]>([]);
@@ -52,6 +59,7 @@ const visits = ref<any[]>([]);
52
59
  const visitsLoading = ref(false);
53
60
  const now = Date.now();
54
61
  const rows = computed(() => items.value.map((r) => patrolRecordRow(r, now)));
62
+ const shownRows = computed(() => (short.value ? rows.value.slice(0, 5) : rows.value));
55
63
 
56
64
  /** Only http(s) links from the brand are rendered. */
57
65
  const safeUrl = (u: unknown) => (typeof u === "string" && /^https?:\/\//i.test(u) ? u : "");
@@ -89,6 +97,8 @@ watch(() => props.robotId, () => load(1), { immediate: true });
89
97
 
90
98
  <style scoped>
91
99
  .hero-patrolhist { margin-top: 20px; }
100
+ .hero-patrolhist--compact { margin-top: 0; }
101
+ .hero-patrolhist--compact .hero-sched__row { grid-template-columns: 76px minmax(0, 1fr) auto; }
92
102
  .hero-patrolhist__row { width: 100%; text-align: left; background: none; border: 0; cursor: pointer; color: inherit; font: inherit; }
93
103
  .hero-patrolhist__visits { padding: 4px 0 8px 16px; }
94
104
  .hero-patrolhist__visit { display: flex; flex-wrap: wrap; gap: 12px; padding: 4px 0; font-size: 12px; }
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.35.1-staging.410",
5
+ "version": "4.35.1-staging.412",
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.",
@@ -1156,6 +1156,107 @@ export function patrolRunHint(records: Array<{ routeName?: unknown; startedAt?:
1156
1156
  return `Looks like "${best.name}" runs ${how} around ${hhmm} (based on the last 7 days)`;
1157
1157
  }
1158
1158
 
1159
+ /* ------------------------------------------ patrol schedule detected from past runs */
1160
+
1161
+ export type TPatrolSlot = {
1162
+ route: string;
1163
+ /** Minutes after local midnight. */
1164
+ minutes: number;
1165
+ hhmm: string;
1166
+ /** "daily" · "every Mon, Thu" · "on 4 of the last 30 days". */
1167
+ days: string;
1168
+ dayCount: number;
1169
+ lastAt: number;
1170
+ /** Next expected start, only while the pattern is still current. */
1171
+ nextAt: number | null;
1172
+ };
1173
+
1174
+ const DAY_MS = 86_400_000;
1175
+ const WEEKDAY = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
1176
+ const hhmmOf = (min: number) => `${String(Math.floor(min / 60) % 24).padStart(2, "0")}:${String(min % 60).padStart(2, "0")}`;
1177
+ const localDay = (t: number) => {
1178
+ const d = new Date(t);
1179
+ return new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
1180
+ };
1181
+ const atMinutes = (day: number, min: number) => {
1182
+ const d = new Date(day);
1183
+ return new Date(d.getFullYear(), d.getMonth(), d.getDate(), Math.floor(min / 60), min % 60).getTime();
1184
+ };
1185
+
1186
+ /**
1187
+ * The robot's brand keeps its patrol schedule to itself (PadBot has no
1188
+ * schedule API), so we detect it from the robot's own recent runs: runs of
1189
+ * the same route that start within 30 minutes of each other (local clock) on
1190
+ * at least 2 different days in the last `windowDays` form one slot.
1191
+ * Every day between the first and last run -> "daily"; the same weekdays
1192
+ * repeated -> "every Mon, Thu"; else "on N of the last 30 days". The next
1193
+ * start is only predicted while the pattern is current (daily: ran in the
1194
+ * last 2 days; weekly: in the last 8). A one-off run is never a slot.
1195
+ */
1196
+ export function detectPatrolSchedule(
1197
+ records: Array<{ routeName?: unknown; startedAt?: unknown }> | null | undefined,
1198
+ now: number = Date.now(),
1199
+ windowDays = 30,
1200
+ ): TPatrolSlot[] {
1201
+ const since = now - windowDays * DAY_MS;
1202
+ const byRoute = new Map<string, Array<{ t: number; min: number }>>();
1203
+ for (const r of records ?? []) {
1204
+ const t = typeof r?.startedAt === "string" ? Date.parse(r.startedAt) : NaN;
1205
+ const name = typeof r?.routeName === "string" ? r.routeName.trim() : "";
1206
+ if (!name || !finite(t) || t < since || t > now) continue;
1207
+ const d = new Date(t);
1208
+ byRoute.set(name, [...(byRoute.get(name) ?? []), { t, min: d.getHours() * 60 + d.getMinutes() }]);
1209
+ }
1210
+ const slots: TPatrolSlot[] = [];
1211
+ for (const [route, runs] of byRoute) {
1212
+ // Group by time of day; a gap over 30 min starts a new group (a group may wrap past midnight).
1213
+ const sorted = [...runs].sort((a, b) => a.min - b.min);
1214
+ const groups: Array<typeof sorted> = [];
1215
+ for (const r of sorted) {
1216
+ const g = groups[groups.length - 1];
1217
+ if (g && r.min - g[g.length - 1]!.min <= 30) g.push(r);
1218
+ else groups.push([r]);
1219
+ }
1220
+ if (groups.length > 1) {
1221
+ const first = groups[0]!, last = groups[groups.length - 1]!;
1222
+ if (first[0]!.min + 1440 - last[last.length - 1]!.min <= 30) groups[0] = [...groups.pop()!.map((r) => ({ ...r, min: r.min - 1440 })), ...first];
1223
+ }
1224
+ for (const g of groups) {
1225
+ const days = [...new Set(g.map((r) => localDay(r.t)))].sort((a, b) => a - b);
1226
+ if (days.length < 2) continue;
1227
+ const mins = g.map((r) => r.min).sort((a, b) => a - b);
1228
+ const minutes = (mins[Math.floor((mins.length - 1) / 2)]! + 1440) % 1440;
1229
+ const lastAt = Math.max(...g.map((r) => r.t));
1230
+ const span = Math.round((days[days.length - 1]! - days[0]!) / DAY_MS) + 1;
1231
+ const wdCount = new Map<number, number>();
1232
+ for (const d of days) wdCount.set(new Date(d).getDay(), (wdCount.get(new Date(d).getDay()) ?? 0) + 1);
1233
+ const weekly = span > 7 && wdCount.size <= 5 && [...wdCount.values()].every((n) => n >= 2);
1234
+ const kind = days.length === span ? "daily" : weekly ? "weekly" : "some";
1235
+ const label =
1236
+ kind === "daily" ? "daily" : kind === "weekly" ? `every ${[...wdCount.keys()].sort((a, b) => ((a + 6) % 7) - ((b + 6) % 7)).map((d) => WEEKDAY[d]).join(", ")}` : `on ${days.length} of the last ${windowDays} days`;
1237
+ let nextAt: number | null = null;
1238
+ if ((kind === "daily" && now - lastAt <= 2 * DAY_MS) || (kind === "weekly" && now - lastAt <= 8 * DAY_MS)) {
1239
+ for (let i = 0; i <= 7 && nextAt === null; i++) {
1240
+ const at = atMinutes(localDay(now) + i * DAY_MS + DAY_MS / 2, minutes); // noon offset survives DST days
1241
+ if (at > now && (kind === "daily" || wdCount.has(new Date(at).getDay()))) nextAt = at;
1242
+ }
1243
+ }
1244
+ slots.push({ route, minutes, hhmm: hhmmOf(minutes), days: label, dayCount: days.length, lastAt, nextAt });
1245
+ }
1246
+ }
1247
+ return slots.sort((a, b) => a.minutes - b.minutes || a.route.localeCompare(b.route));
1248
+ }
1249
+
1250
+ /** One slot as a line: "daily 20:00 · last ran 19 h ago · next expected 20:00 today". */
1251
+ export function patrolSlotText(slot: TPatrolSlot, now: number = Date.now()): string {
1252
+ const parts = [`${slot.days} ${slot.hhmm}`, `last ran ${agoText(slot.lastAt, now)}`];
1253
+ if (slot.nextAt !== null) {
1254
+ const d = Math.round((localDay(slot.nextAt) - localDay(now)) / DAY_MS);
1255
+ parts.push(`next expected ${slot.hhmm} ${d === 0 ? "today" : d === 1 ? "tomorrow" : WEEKDAY[new Date(slot.nextAt).getDay()]}`);
1256
+ }
1257
+ return parts.join(" · ");
1258
+ }
1259
+
1159
1260
  /**
1160
1261
  * May the plan take spot-clean clicks? A Manager, a cleaning robot, a brand
1161
1262
  * that declares `spotClean`, and a robot confirmed online. (A busy robot keeps