@7365admin1/layer-common 4.58.2 → 4.59.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.59.0
4
+
5
+ ### Minor Changes
6
+
7
+ - d7cf74d: Robot detail laid out for the operator: header (name, status, site, model · serial, Edit/Remove), one-row status strip, camera and live map side by side above the fold with the commands under the map (map full width for robots without a camera; stacks camera, map, commands, patrol when narrow), then Patrol schedule next to Recent patrols (5 rows, See all), this robot's Alerts, and "What this robot can do" folded at the bottom. New `detectPatrolSchedule` / `patrolSlotText`: for brands without a schedule API (PadBot) the patrol schedule is detected from the last 30 days of runs ("Patrolling Lvl3 · daily 20:00 · last ran 18 h ago · next expected 20:00 today"), clearly labelled.
8
+
3
9
  ## 4.58.2
4
10
 
5
11
  ### Patch Changes
@@ -1630,11 +1636,11 @@
1630
1636
  `utils/console-tier.ts` + `composables/useConsoleTier.ts` mirror the server's
1631
1637
  own rule from two endpoints the console already calls, unprojected:
1632
1638
 
1633
- GET /api/members/user/:user/app/admin the Seven365 staff membership
1634
- GET /api/roles/id/:role that membership's role document
1639
+ GET /api/members/user/:user/app/admin the Seven365 staff membership
1640
+ GET /api/roles/id/:role that membership's role document
1635
1641
 
1636
- owner = member.type === "admin" && role.type === "admin" && role.default === true
1637
- staff = member.type === "admin" && role.type === "admin"
1642
+ owner = member.type === "admin" && role.type === "admin" && role.default === true
1643
+ staff = member.type === "admin" && role.type === "admin"
1638
1644
 
1639
1645
  `role.default` is the marker because it is the only property of a platform
1640
1646
  staff role no API caller can set - `role.controller.ts` validates create and
@@ -801,6 +801,32 @@
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__main .hero-plan--detail { max-height: 280px; }
813
+ .hero-detail__main--single .hero-plan--detail { max-height: 360px; }
814
+ .hero-detail__main .hero-livepanel .hero-dialog__actions:empty { display: none; }
815
+ .hero-detail__commands { border-top: 2px solid var(--hero-divider); padding-top: 10px; }
816
+ .hero-detail__alertcode { color: var(--hero-accent); }
817
+ @media (max-width: 900px) { .hero-detail__main { grid-template-columns: minmax(0, 1fr); } }
818
+ .hero-sched__note { font-size: 12px; opacity: 0.65; margin-bottom: 4px; }
819
+ .hero-sched__row.hero-sched__row--slot { grid-template-columns: minmax(110px, 160px) minmax(0, 1fr); }
820
+ .hero-sched__row.hero-sched__row--alert { grid-template-columns: 110px minmax(0, 1fr) auto; }
821
+ .hero-detail__sub { display: flex; flex-wrap: wrap; gap: 4px 14px; opacity: 0.65; font-size: 12px; margin-top: 2px; }
822
+ .hero-detail__title--main { flex-wrap: nowrap; min-width: 0; }
823
+ .hero-detail__title--main h2 { overflow: hidden; text-overflow: ellipsis; min-width: 8ch; }
824
+ @media (max-width: 700px) { .hero-detail__title--main { flex-wrap: wrap; } .hero-detail__title--main h2 { flex-basis: 100%; } }
825
+ @media (max-width: 560px) { .hero-faultbar { grid-auto-flow: row; } .hero-faultbar > .v-btn, .hero-faultbar__badge { justify-self: start; } }
826
+ .hero-detail__main .hero-mapsel { margin: 0; }
827
+ .hero-can--fold { padding: 8px 14px; }
828
+ .hero-can--fold > summary { cursor: pointer; list-style: revert; }
829
+ .hero-can--fold[open] > summary { margin-bottom: 6px; }
804
830
  .hero-detail__grid .hero-aside__hint { opacity: 0.55; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; min-width: 0; }
805
831
 
806
832
  .hero-sched__empty { padding: 20px 0; opacity: 0.65; }
@@ -1,8 +1,16 @@
1
1
  <template>
2
- <!-- Robot detail (README §3). -->
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
+ -->
3
11
  <div>
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
58
 
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>
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,6 +328,7 @@ 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);
@@ -363,14 +384,25 @@ const faultSince = computed(() => sinceText(fault.value?.since, now.value));
363
384
 
364
385
  // Only what THIS robot reports (hide, never grey); what its maker keeps back is said once, in the card.
365
386
  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)));
387
+ // The strip: connection · battery · what it is doing · floor · last seen (model and serial sit in the header).
388
+ const STRIP_ORDER = ["Connection", "Battery", "Robot reports", "Floor", "Last seen"];
389
+ const stripFacts = computed(() =>
390
+ STRIP_ORDER.map((l) => facts.value.find((f) => f.label === l))
391
+ .filter((f): f is NonNullable<typeof f> => !!f)
392
+ .map((f) => (f.label === "Robot reports" ? { ...f, label: "Doing now" } : f)),
393
+ );
394
+ const identity = computed(() => {
395
+ const model = facts.value.find((f) => f.label === "Model")?.value;
396
+ const serial = facts.value.find((f) => f.label === "Serial")?.value;
397
+ return [model, serial ? `Serial ${serial}` : ""].filter(Boolean).join(" · ");
398
+ });
368
399
  // 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)));
400
+ // Floor is in the strip; the plan's readout shows the live position, so the cell only when there is no plan.
401
+ const locationFacts = computed(() => facts.value.filter((f) => f.label === "Live position" && !showPlan.value));
370
402
  const abilities = computed(() =>
371
403
  robotAbilities({ capabilities: caps.value, profile: live.value?.ok ? live.value.profile : null, type: robotType.value, brand: brand.value }),
372
404
  );
373
- const scheduleElsewhere = computed(() => scheduleElsewhereText(robotType.value, brand.value, live.value?.ok ? live.value.profile : null));
405
+ const makerApp = computed(() => (live.value?.ok ? live.value.profile?.makerApp : "") || `${brand.value}'s own app`);
374
406
 
375
407
  const online = computed(() => live.value?.ok === true && live.value.online === true);
376
408
  const commands = computed(() =>
@@ -408,7 +440,7 @@ const spotOn = computed(() => showSpotClean({ canCommand: props.canCommand, capa
408
440
  const route = computed<TPatrolRouteView | null>(() =>
409
441
  selectedMap.value ? mapRoutes.value[0] ?? null : patrol.value?.routes?.find((r) => r.checkpoints?.length) ?? null,
410
442
  );
411
- const floorText = computed(() => locationFacts.value.find((f) => f.label === "Floor")?.value ?? "");
443
+ const floorText = computed(() => facts.value.find((f) => f.label === "Floor")?.value ?? "");
412
444
  // 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
445
  const showPlan = computed(
414
446
  () => !caps.value || reportsPosition.value || spotOn.value || mapViews.value.length > 0 || !!route.value?.checkpoints?.length,
@@ -433,17 +465,34 @@ const schedule = computed(() => (robotType.value === "security" ? patrolSchedule
433
465
  const unplacedRoutes = computed(() =>
434
466
  (patrol.value?.routes ?? []).map((r) => ({ id: r.id, name: r.name, text: unplacedStopsText(r) })).filter((r) => r.text),
435
467
  );
436
- // The brand keeps its schedules to itself: recent runs (and a labelled guess) instead.
468
+ // The brand keeps its schedules to itself (PadBot): detect them from the last 30 days of runs.
437
469
  const runs = ref<any[]>([]);
438
- const runHint = computed(() => patrolRunHint(runs.value, now.value));
470
+ const runsLoaded = ref(false);
471
+ const detected = computed(() => detectPatrolSchedule(runs.value, now.value));
472
+ /** 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. */
473
+ async function loadRuns() {
474
+ const since = Date.now() - 30 * 86_400_000;
475
+ const all: any[] = [];
476
+ for (let page = 1; page <= 5; page++) {
477
+ const res = await getPatrolHistory(props.robotId, { page, limit: 100 }).catch(() => null);
478
+ const items = res?.items ?? [];
479
+ all.push(...items);
480
+ const oldest = Date.parse(items[items.length - 1]?.startedAt ?? "");
481
+ if (!items.length || page >= (res?.pages ?? 0) || !(oldest >= since)) break;
482
+ }
483
+ runs.value = all;
484
+ runsLoaded.value = true;
485
+ }
439
486
 
487
+ let runsLoading = false;
440
488
  async function loadSchedule() {
441
489
  if (!record.value || noInterface.value) return;
442
490
  try {
443
491
  if (robotType.value === "security") {
444
492
  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 ?? [];
493
+ if (patrol.value?.scheduleShared === false && caps.value?.patrolHistory === true && !runsLoaded.value && !runsLoading) {
494
+ runsLoading = true;
495
+ loadRuns().finally(() => (runsLoading = false));
447
496
  }
448
497
  } else plans.value = (await getRobotPlans(props.robotId))?.items ?? [];
449
498
  } catch {
@@ -613,6 +662,7 @@ function onPick(p: TSavedPoint) {
613
662
 
614
663
  /* ---- security-robot extras: live view (minted per open, never kept), manual control, resolve an alarm ---- */
615
664
  const livePanel = ref<{ show(): void } | null>(null);
665
+ const hasCamera = computed(() => caps.value?.liveVideo === true || canDrive.value);
616
666
  const driveOpen = ref(false);
617
667
  const canDrive = computed(() => props.canCommand && caps.value?.manualDrive === true && online.value);
618
668
  const canResolve = computed(
@@ -631,6 +681,23 @@ async function resolveFault() {
631
681
  }
632
682
  }
633
683
 
684
+ /* ---- this robot's open alerts: the site's alert list, narrowed to this robot ---- */
685
+ const siteAlerts = ref<any[]>([]);
686
+ const robotAlerts = computed(() =>
687
+ siteAlerts.value
688
+ .filter((a) => String(a?.robot?._id ?? "") === props.robotId)
689
+ .map((a, i) => ({ key: `${a?.code ?? ""}-${i}`, ...attentionText(a), since: a?.since ? `Since ${sinceText(a.since, now.value)}` : "" })),
690
+ );
691
+ async function loadAlerts() {
692
+ const site = String(record.value?.site?._id ?? "");
693
+ if (!site) return;
694
+ try {
695
+ siteAlerts.value = (await getSiteFaults(site, { page: 1, limit: 100 }))?.items ?? [];
696
+ } catch {
697
+ siteAlerts.value = [];
698
+ }
699
+ }
700
+
634
701
  /* ---- edit / remove ---- */
635
702
  function onSaved() {
636
703
  editOpen.value = false;
@@ -674,9 +741,13 @@ watch(
674
741
  lastPose.value = null;
675
742
  prevPose.value = nextPose.value = null;
676
743
  streamLost.value = false;
744
+ runs.value = [];
745
+ runsLoaded.value = false;
746
+ siteAlerts.value = [];
677
747
  await loadRecord();
678
748
  loadSchedule();
679
749
  loadMaps();
750
+ loadAlerts();
680
751
  },
681
752
  { immediate: true },
682
753
  );
@@ -696,6 +767,7 @@ const timer = setInterval(() => {
696
767
  now.value = Date.now();
697
768
  if (typeof document !== "undefined" && document.visibilityState === "hidden") return;
698
769
  if (++ticks % 8 === 0 && robotType.value === "security") loadSchedule();
770
+ if (ticks % 30 === 0) loadAlerts();
699
771
  }, 1000);
700
772
  onBeforeUnmount(() => {
701
773
  clearInterval(timer);
@@ -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.58.2",
5
+ "version": "4.59.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.",
@@ -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