@7365admin1/layer-common 3.2.2-staging.183 → 3.2.2-staging.188
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 +6 -0
- package/assets/css/primitives.css +22 -0
- package/assets/css/screens.css +10 -1
- package/components/DashboardEmptyState.vue +37 -2
- package/components/DashboardMain.vue +247 -99
- package/components/Layout/Header.vue +7 -32
- package/components/SwitchContext.vue +30 -1
- package/package.json +1 -1
- package/utils/breadcrumb.test.ts +108 -0
- package/utils/breadcrumb.ts +82 -0
- package/utils/dashboard.test.ts +118 -0
- package/utils/dashboard.ts +53 -0
- package/utils/theme-aa-ledger.ts +9 -3
- package/utils/theme.test.ts +88 -4
package/CHANGELOG.md
CHANGED
|
@@ -614,6 +614,28 @@ button.app-btn--err {
|
|
|
614
614
|
opacity: 1;
|
|
615
615
|
}
|
|
616
616
|
|
|
617
|
+
/*
|
|
618
|
+
* A native `<select>`'s option list is painted by the BROWSER, not by the page.
|
|
619
|
+
* The control above sets `color`, and an `<option>` inherits it - but it gets
|
|
620
|
+
* no background of its own, so the browser paints its own popup behind the
|
|
621
|
+
* theme's ink. In dark mode that popup is white on Windows/Chrome, and the
|
|
622
|
+
* theme's near-white ink on it measures 1.14:1: the list reads as blank, with
|
|
623
|
+
* only the highlighted row legible. QA reported exactly that on two apps.
|
|
624
|
+
*
|
|
625
|
+
* Giving the option the card's own fill and ink is what makes the open list
|
|
626
|
+
* belong to the theme. `--card`/`--text` and not a literal, so it follows the
|
|
627
|
+
* `v-theme--*` subtree the select is actually in - a dialog that forces a
|
|
628
|
+
* theme included. Declared once here rather than per component, so every
|
|
629
|
+
* native select this layer renders is covered, in every consuming app.
|
|
630
|
+
*
|
|
631
|
+
* The FRAME around the list is browser chrome and is not ours to paint; a
|
|
632
|
+
* light strip may remain around the options in some browsers.
|
|
633
|
+
*/
|
|
634
|
+
select option {
|
|
635
|
+
background-color: var(--card);
|
|
636
|
+
color: var(--text);
|
|
637
|
+
}
|
|
638
|
+
|
|
617
639
|
/* The `trailing` slot's control - same muted weight as the leading icon, so a
|
|
618
640
|
reveal toggle reads as field chrome rather than as a second action. */
|
|
619
641
|
.app-field__trailing {
|
package/assets/css/screens.css
CHANGED
|
@@ -646,7 +646,16 @@
|
|
|
646
646
|
*/
|
|
647
647
|
.table-card thead th {
|
|
648
648
|
background: var(--thead) !important;
|
|
649
|
-
|
|
649
|
+
/*
|
|
650
|
+
* `--text2`, not the prototype's `--muted`. `--muted` on the header band
|
|
651
|
+
* measures 3.10:1 in light (3.24:1 where the band sits on a plain card) -
|
|
652
|
+
* below AA, and reported independently by two apps' QA runs. `--text2` is
|
|
653
|
+
* the design's SECONDARY text token: 9.22:1 light / 9.57:1 dark on the same
|
|
654
|
+
* band, while still sitting clearly below the primary ink the data rows use
|
|
655
|
+
* (16.52:1 / 13.77:1), so the header keeps reading as secondary to the data.
|
|
656
|
+
* The uppercase/800/11px treatment below is unchanged.
|
|
657
|
+
*/
|
|
658
|
+
color: var(--text2) !important;
|
|
650
659
|
font-size: var(--fs-table-head) !important;
|
|
651
660
|
font-weight: var(--fw-table-head) !important;
|
|
652
661
|
letter-spacing: var(--ls-table-head);
|
|
@@ -1,10 +1,28 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
"No data to display" and "we could not fetch it" are not the same sentence.
|
|
3
|
+
|
|
4
|
+
This component drew the first one for both, because every dashboard request
|
|
5
|
+
defaulted a rejection to an empty list. `error` splits them: a genuine zero
|
|
6
|
+
keeps the neutral wording, a failed request says so in the error tone and
|
|
7
|
+
tells the reader the figure is missing rather than nil.
|
|
8
|
+
-->
|
|
1
9
|
<template>
|
|
2
10
|
<div
|
|
3
11
|
class="d-flex flex-column align-center justify-center text-center dashboard-empty"
|
|
12
|
+
:class="{ 'dashboard-empty--error': error }"
|
|
4
13
|
:style="{ minHeight: compact ? '72px' : '140px', gap: '8px' }"
|
|
14
|
+
role="status"
|
|
5
15
|
>
|
|
6
|
-
<v-icon
|
|
7
|
-
|
|
16
|
+
<v-icon
|
|
17
|
+
:icon="error ? 'mdi-cloud-alert-outline' : 'mdi-database-search-outline'"
|
|
18
|
+
size="28"
|
|
19
|
+
/>
|
|
20
|
+
<span class="text-caption font-weight-bold">
|
|
21
|
+
{{ error ? "Couldn't load this data" : "No data to display" }}
|
|
22
|
+
</span>
|
|
23
|
+
<span v-if="error" class="text-caption dashboard-empty__hint">
|
|
24
|
+
The request failed — this is not a zero. Refresh to try again.
|
|
25
|
+
</span>
|
|
8
26
|
</div>
|
|
9
27
|
</template>
|
|
10
28
|
|
|
@@ -14,6 +32,11 @@ defineProps({
|
|
|
14
32
|
type: Boolean,
|
|
15
33
|
default: false,
|
|
16
34
|
},
|
|
35
|
+
/** The request behind this panel failed - say so instead of showing "none". */
|
|
36
|
+
error: {
|
|
37
|
+
type: Boolean,
|
|
38
|
+
default: false,
|
|
39
|
+
},
|
|
17
40
|
});
|
|
18
41
|
</script>
|
|
19
42
|
|
|
@@ -25,4 +48,16 @@ defineProps({
|
|
|
25
48
|
font-family: var(--font-sans);
|
|
26
49
|
color: var(--muted);
|
|
27
50
|
}
|
|
51
|
+
|
|
52
|
+
/* `--err` is the design's own error tone and is AA in both themes; the hint
|
|
53
|
+
line stays on `--text2` so the explanation is never the palest thing here
|
|
54
|
+
(a dimmed explanation is the one line that must stay readable). */
|
|
55
|
+
.dashboard-empty--error {
|
|
56
|
+
color: var(--err);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
.dashboard-empty__hint {
|
|
60
|
+
color: var(--text2);
|
|
61
|
+
max-width: 34ch;
|
|
62
|
+
}
|
|
28
63
|
</style>
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
<template
|
|
22
22
|
v-if="card.key === 'supplyAlert' && Array.isArray(card.raw?.items)"
|
|
23
23
|
>
|
|
24
|
-
<div class="d-flex flex-column ga-2 mt-2">
|
|
24
|
+
<div v-if="card.raw.items.length" class="d-flex flex-column ga-2 mt-2">
|
|
25
25
|
<div
|
|
26
26
|
v-for="item in card.raw.items"
|
|
27
27
|
:key="item._id || item.name"
|
|
@@ -31,6 +31,7 @@
|
|
|
31
31
|
<span class="text-blue-grey">Balance: {{ item.qty }}</span>
|
|
32
32
|
</div>
|
|
33
33
|
</div>
|
|
34
|
+
<div v-else class="print-kpi-meta mt-2">No supplies recorded</div>
|
|
34
35
|
</template>
|
|
35
36
|
<template v-else>
|
|
36
37
|
<div class="print-kpi-value">{{ card.value }}</div>
|
|
@@ -254,6 +255,27 @@
|
|
|
254
255
|
class="mb-4"
|
|
255
256
|
/>
|
|
256
257
|
|
|
258
|
+
<!--
|
|
259
|
+
SAY IT ONCE AT THE TOP, THEN AGAIN WHERE IT HAPPENED. A failed request
|
|
260
|
+
used to be invisible: zeros in the tiles, "No data to display" in the
|
|
261
|
+
lists, nothing else. This names what did not load; each tile and panel
|
|
262
|
+
below carries its own failure too, so nobody has to guess which zero is
|
|
263
|
+
real. `variant="tonal"` on purpose - Vuetify derives a WHITE label for a
|
|
264
|
+
filled error alert, which measures 3.60:1 in these themes.
|
|
265
|
+
-->
|
|
266
|
+
<v-alert
|
|
267
|
+
v-if="failedWidgetLabels.length"
|
|
268
|
+
type="error"
|
|
269
|
+
variant="tonal"
|
|
270
|
+
density="compact"
|
|
271
|
+
icon="mdi-cloud-alert-outline"
|
|
272
|
+
class="mb-4"
|
|
273
|
+
>
|
|
274
|
+
Some of this dashboard could not be loaded ({{
|
|
275
|
+
failedWidgetLabels.join(", ")
|
|
276
|
+
}}). The figures for it are missing, not zero — refresh to try again.
|
|
277
|
+
</v-alert>
|
|
278
|
+
|
|
257
279
|
<!-- ── KPI Cards ───────────────────────────────────────────────────────── -->
|
|
258
280
|
<v-row dense class="mb-1 align-stretch">
|
|
259
281
|
<v-col
|
|
@@ -332,7 +354,11 @@
|
|
|
332
354
|
<template
|
|
333
355
|
v-if="card.key === 'supplyAlert' && Array.isArray(card.raw?.items)"
|
|
334
356
|
>
|
|
335
|
-
|
|
357
|
+
<!--
|
|
358
|
+
An empty list used to render a title and a blank card - no number,
|
|
359
|
+
no words, nothing to read. The tile now says what it found.
|
|
360
|
+
-->
|
|
361
|
+
<div v-if="card.raw.items.length" class="d-flex flex-column ga-2">
|
|
336
362
|
<div
|
|
337
363
|
v-for="item in card.raw.items"
|
|
338
364
|
:key="item._id || item.name"
|
|
@@ -342,6 +368,7 @@
|
|
|
342
368
|
<span class="text-medium-emphasis">Balance: {{ item.qty }}</span>
|
|
343
369
|
</div>
|
|
344
370
|
</div>
|
|
371
|
+
<DashboardEmptyState v-else compact />
|
|
345
372
|
</template>
|
|
346
373
|
</AppKpiTile>
|
|
347
374
|
</v-col>
|
|
@@ -488,7 +515,7 @@
|
|
|
488
515
|
</div>
|
|
489
516
|
</div>
|
|
490
517
|
|
|
491
|
-
<DashboardEmptyState v-else />
|
|
518
|
+
<DashboardEmptyState v-else :error="chartFailed" />
|
|
492
519
|
</v-card>
|
|
493
520
|
|
|
494
521
|
<!-- ── Hygiene / M&E / Landscape / Pest / Pool — task + attendance ────── -->
|
|
@@ -533,7 +560,7 @@
|
|
|
533
560
|
</button>
|
|
534
561
|
</div>
|
|
535
562
|
</div>
|
|
536
|
-
<DashboardEmptyState v-else />
|
|
563
|
+
<DashboardEmptyState v-else :error="taskScheduleFailed" />
|
|
537
564
|
</DashboardPanel>
|
|
538
565
|
</v-col>
|
|
539
566
|
|
|
@@ -565,7 +592,7 @@
|
|
|
565
592
|
<span class="figma-row-figure">{{ item.totalHours || 0 }} hrs</span>
|
|
566
593
|
</div>
|
|
567
594
|
</div>
|
|
568
|
-
<DashboardEmptyState v-else />
|
|
595
|
+
<DashboardEmptyState v-else :error="attendanceFailed" />
|
|
569
596
|
</DashboardPanel>
|
|
570
597
|
</v-col>
|
|
571
598
|
|
|
@@ -599,7 +626,7 @@
|
|
|
599
626
|
</button>
|
|
600
627
|
</div>
|
|
601
628
|
</div>
|
|
602
|
-
<DashboardEmptyState v-else />
|
|
629
|
+
<DashboardEmptyState v-else :error="feedbacksFailed" />
|
|
603
630
|
</DashboardPanel>
|
|
604
631
|
</v-col>
|
|
605
632
|
</v-row>
|
|
@@ -644,7 +671,7 @@
|
|
|
644
671
|
</button>
|
|
645
672
|
</div>
|
|
646
673
|
</div>
|
|
647
|
-
<DashboardEmptyState v-else />
|
|
674
|
+
<DashboardEmptyState v-else :error="metricsFailed" />
|
|
648
675
|
</div>
|
|
649
676
|
</div>
|
|
650
677
|
</v-col>
|
|
@@ -689,7 +716,7 @@
|
|
|
689
716
|
</button>
|
|
690
717
|
</div>
|
|
691
718
|
</div>
|
|
692
|
-
<DashboardEmptyState v-else />
|
|
719
|
+
<DashboardEmptyState v-else :error="metricsFailed" />
|
|
693
720
|
</div>
|
|
694
721
|
</div>
|
|
695
722
|
</v-col>
|
|
@@ -738,7 +765,7 @@
|
|
|
738
765
|
</button>
|
|
739
766
|
</div>
|
|
740
767
|
</div>
|
|
741
|
-
<DashboardEmptyState v-else />
|
|
768
|
+
<DashboardEmptyState v-else :error="metricsFailed" />
|
|
742
769
|
</div>
|
|
743
770
|
</div>
|
|
744
771
|
</v-col>
|
|
@@ -774,7 +801,7 @@
|
|
|
774
801
|
</div>
|
|
775
802
|
</div>
|
|
776
803
|
</div>
|
|
777
|
-
<DashboardEmptyState v-else />
|
|
804
|
+
<DashboardEmptyState v-else :error="metricsFailed" />
|
|
778
805
|
</div>
|
|
779
806
|
</div>
|
|
780
807
|
</v-col>
|
|
@@ -822,7 +849,7 @@
|
|
|
822
849
|
</button>
|
|
823
850
|
</div>
|
|
824
851
|
</div>
|
|
825
|
-
<DashboardEmptyState v-else />
|
|
852
|
+
<DashboardEmptyState v-else :error="feedbacksFailed" />
|
|
826
853
|
</DashboardPanel>
|
|
827
854
|
</v-col>
|
|
828
855
|
</v-row>
|
|
@@ -899,7 +926,7 @@
|
|
|
899
926
|
</div>
|
|
900
927
|
</div>
|
|
901
928
|
</div>
|
|
902
|
-
<DashboardEmptyState v-else />
|
|
929
|
+
<DashboardEmptyState v-else :error="metricsFailed" />
|
|
903
930
|
</div>
|
|
904
931
|
</div>
|
|
905
932
|
</v-col>
|
|
@@ -941,7 +968,7 @@
|
|
|
941
968
|
<StatusChip :status="item.status || 'On duty'" pill />
|
|
942
969
|
</div>
|
|
943
970
|
</div>
|
|
944
|
-
<DashboardEmptyState v-else />
|
|
971
|
+
<DashboardEmptyState v-else :error="attendanceFailed" />
|
|
945
972
|
</div>
|
|
946
973
|
</div>
|
|
947
974
|
</v-col>
|
|
@@ -977,7 +1004,7 @@
|
|
|
977
1004
|
</button>
|
|
978
1005
|
</div>
|
|
979
1006
|
</div>
|
|
980
|
-
<DashboardEmptyState v-else />
|
|
1007
|
+
<DashboardEmptyState v-else :error="feedbacksFailed" />
|
|
981
1008
|
</DashboardPanel>
|
|
982
1009
|
</v-col>
|
|
983
1010
|
</v-row>
|
|
@@ -1158,6 +1185,15 @@
|
|
|
1158
1185
|
</template>
|
|
1159
1186
|
|
|
1160
1187
|
<script setup lang="ts">
|
|
1188
|
+
// The one non-auto-imported thing this file needs: the cookie options type, so
|
|
1189
|
+
// `runtimeConfig.public.cookieConfig` (typed `unknown`) can be narrowed once
|
|
1190
|
+
// rather than at the call site. Same import `useThemePreference.ts` uses.
|
|
1191
|
+
import type { CookieOptions } from "#app";
|
|
1192
|
+
// Explicitly, not by auto-import: every other component in this layer reaches
|
|
1193
|
+
// its utils by relative path (`StatusChip.vue`, `CameraWall.vue`,
|
|
1194
|
+
// `plugins/vuetify.ts`), and a bare call resolved to nothing when driven.
|
|
1195
|
+
import { buildWorkOrderStatus } from "../utils/dashboard";
|
|
1196
|
+
|
|
1161
1197
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
|
1162
1198
|
|
|
1163
1199
|
type DashboardMetric = {
|
|
@@ -1455,8 +1491,11 @@ const currentVisitorTypeParam = computed(
|
|
|
1455
1491
|
// ─── Data Fetching ────────────────────────────────────────────────────────────
|
|
1456
1492
|
|
|
1457
1493
|
// Hygiene / M&E / Landscape / Pest / Pool main metrics
|
|
1458
|
-
const {
|
|
1459
|
-
|
|
1494
|
+
const {
|
|
1495
|
+
data: getDashboardReq,
|
|
1496
|
+
pending: loadingMetrics,
|
|
1497
|
+
error: metricsError,
|
|
1498
|
+
} = await useLazyAsyncData(
|
|
1460
1499
|
`get-dashboard-${props.site}-${props.serviceType}`,
|
|
1461
1500
|
() =>
|
|
1462
1501
|
props.serviceType && isHygieneMode.value
|
|
@@ -1472,8 +1511,11 @@ const { data: getDashboardReq, pending: loadingMetrics } =
|
|
|
1472
1511
|
);
|
|
1473
1512
|
|
|
1474
1513
|
// Property / Security main metrics (uses generic getDashboard with visitor type)
|
|
1475
|
-
const {
|
|
1476
|
-
|
|
1514
|
+
const {
|
|
1515
|
+
data: getDashboardAltReq,
|
|
1516
|
+
pending: loadingAltMetrics,
|
|
1517
|
+
error: altMetricsError,
|
|
1518
|
+
} = await useLazyAsyncData(
|
|
1477
1519
|
`get-dashboard-alt-${props.site}-${props.serviceType}`,
|
|
1478
1520
|
() =>
|
|
1479
1521
|
props.site && (isPropertyMode.value || isSecurityMode.value)
|
|
@@ -1497,8 +1539,11 @@ const { data: getDashboardAltReq, pending: loadingAltMetrics } =
|
|
|
1497
1539
|
);
|
|
1498
1540
|
|
|
1499
1541
|
// Weekly activity (all modes)
|
|
1500
|
-
const {
|
|
1501
|
-
|
|
1542
|
+
const {
|
|
1543
|
+
data: weeklyActivityReq,
|
|
1544
|
+
pending: loadingWeeklyActivity,
|
|
1545
|
+
error: weeklyActivityError,
|
|
1546
|
+
} = await useLazyAsyncData(
|
|
1502
1547
|
`get-dashboard-weekly-activity-${props.site}-${props.serviceType}`,
|
|
1503
1548
|
() =>
|
|
1504
1549
|
props.serviceType
|
|
@@ -1514,8 +1559,11 @@ const { data: weeklyActivityReq, pending: loadingWeeklyActivity } =
|
|
|
1514
1559
|
);
|
|
1515
1560
|
|
|
1516
1561
|
// Task schedule (hygiene mode only)
|
|
1517
|
-
const {
|
|
1518
|
-
|
|
1562
|
+
const {
|
|
1563
|
+
data: taskScheduleReq,
|
|
1564
|
+
pending: loadingTaskSchedule,
|
|
1565
|
+
error: taskScheduleError,
|
|
1566
|
+
} = await useLazyAsyncData(
|
|
1519
1567
|
`get-dashboard-task-schedule-${props.site}-${props.serviceType}`,
|
|
1520
1568
|
() =>
|
|
1521
1569
|
props.serviceType && isHygieneMode.value
|
|
@@ -1531,8 +1579,11 @@ const { data: taskScheduleReq, pending: loadingTaskSchedule } =
|
|
|
1531
1579
|
);
|
|
1532
1580
|
|
|
1533
1581
|
// Attendance (hygiene + security modes)
|
|
1534
|
-
const {
|
|
1535
|
-
|
|
1582
|
+
const {
|
|
1583
|
+
data: attendanceReq,
|
|
1584
|
+
pending: loadingAttendance,
|
|
1585
|
+
error: attendanceError,
|
|
1586
|
+
} = await useLazyAsyncData(
|
|
1536
1587
|
`get-dashboard-attendance-${props.site}-${props.serviceType}`,
|
|
1537
1588
|
() =>
|
|
1538
1589
|
props.serviceType && !isPropertyMode.value
|
|
@@ -1548,8 +1599,11 @@ const { data: attendanceReq, pending: loadingAttendance } =
|
|
|
1548
1599
|
);
|
|
1549
1600
|
|
|
1550
1601
|
// Feedbacks (all modes)
|
|
1551
|
-
const {
|
|
1552
|
-
|
|
1602
|
+
const {
|
|
1603
|
+
data: feedbacksReq,
|
|
1604
|
+
pending: loadingFeedbacks,
|
|
1605
|
+
error: feedbacksError,
|
|
1606
|
+
} = await useLazyAsyncData(
|
|
1553
1607
|
`get-dashboard-feedbacks-${props.site}-${props.serviceType}`,
|
|
1554
1608
|
() =>
|
|
1555
1609
|
props.serviceType
|
|
@@ -1593,35 +1647,64 @@ const finalLoading = computed(() => {
|
|
|
1593
1647
|
);
|
|
1594
1648
|
});
|
|
1595
1649
|
|
|
1650
|
+
// ─── Request Failures ─────────────────────────────────────────────────────────
|
|
1651
|
+
|
|
1652
|
+
/**
|
|
1653
|
+
* A DEAD API USED TO LOOK EXACTLY LIKE A QUIET DAY.
|
|
1654
|
+
*
|
|
1655
|
+
* Every request's result was read as `x.value ?? <zero>`, so a rejected call
|
|
1656
|
+
* produced the same screen as a site with nothing on it: every tile 0, every
|
|
1657
|
+
* trend +0%, every list "No data to display", and not one word to say the data
|
|
1658
|
+
* never arrived. That ambiguity has cost this estate days before now.
|
|
1659
|
+
*
|
|
1660
|
+
* `useLazyAsyncData` already hands back an `error` ref; these carry it to the
|
|
1661
|
+
* widgets that asked for the data, so a zero on this page is a measurement and
|
|
1662
|
+
* a failure says so.
|
|
1663
|
+
*/
|
|
1664
|
+
const metricsFailed = computed(() =>
|
|
1665
|
+
Boolean(isHygieneMode.value ? metricsError.value : altMetricsError.value)
|
|
1666
|
+
);
|
|
1667
|
+
const chartFailed = computed(() => Boolean(weeklyActivityError.value));
|
|
1668
|
+
const taskScheduleFailed = computed(() => Boolean(taskScheduleError.value));
|
|
1669
|
+
const attendanceFailed = computed(() => Boolean(attendanceError.value));
|
|
1670
|
+
const feedbacksFailed = computed(() => Boolean(feedbacksError.value));
|
|
1671
|
+
|
|
1672
|
+
/** The header banner - one line, whatever combination failed. */
|
|
1673
|
+
const failedWidgetLabels = computed(() => {
|
|
1674
|
+
const labels: string[] = [];
|
|
1675
|
+
if (metricsFailed.value) labels.push("the summary tiles");
|
|
1676
|
+
if (chartFailed.value) labels.push("the activity chart");
|
|
1677
|
+
if (taskScheduleFailed.value && isHygieneMode.value)
|
|
1678
|
+
labels.push(primaryListConfig.value.title.toLowerCase());
|
|
1679
|
+
if (attendanceFailed.value && !isPropertyMode.value) labels.push("staff");
|
|
1680
|
+
if (feedbacksFailed.value) labels.push("feedbacks");
|
|
1681
|
+
return labels;
|
|
1682
|
+
});
|
|
1683
|
+
|
|
1684
|
+
/** The text a tile or panel shows in place of a figure it does not have. */
|
|
1685
|
+
const LOAD_FAILED_TEXT = "Couldn't load";
|
|
1686
|
+
|
|
1596
1687
|
// ─── Merged Dashboard Data ────────────────────────────────────────────────────
|
|
1597
1688
|
|
|
1598
1689
|
const finalDashboardData = computed<Record<string, any>>(() => {
|
|
1599
1690
|
if (!props.serviceType) return props.dashboardData || {};
|
|
1600
1691
|
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
{ day: "Tue", workOrder: 450, taskCompleted: 900 },
|
|
1618
|
-
{ day: "Wed", workOrder: 950, taskCompleted: 1640 },
|
|
1619
|
-
{ day: "Thu", workOrder: 2000, taskCompleted: 2100 },
|
|
1620
|
-
{ day: "Fri", workOrder: 2000, taskCompleted: 2700 },
|
|
1621
|
-
{ day: "Sat", workOrder: 2200, taskCompleted: 3100 },
|
|
1622
|
-
{ day: "Sun", workOrder: 2800, taskCompleted: 3200 },
|
|
1623
|
-
];
|
|
1624
|
-
}
|
|
1692
|
+
/**
|
|
1693
|
+
* A QUIET WEEK IS NOT A BUSY ONE.
|
|
1694
|
+
*
|
|
1695
|
+
* This used to substitute a hard-coded seven-day curve - Mon 800/1400 through
|
|
1696
|
+
* Sun 2800/3200 - whenever the week's activity came back with nothing in it,
|
|
1697
|
+
* and draw it as real, y-axis to 3,200. The backend zero-fills one row per
|
|
1698
|
+
* day and never returns an empty array, so "nothing in it" is exactly what a
|
|
1699
|
+
* genuinely quiet site looks like; `?? []` made it exactly what a FAILED
|
|
1700
|
+
* request looks like too. And it left the screen: Export -> Word wrote those
|
|
1701
|
+
* invented figures into a document a manager can hand to a client.
|
|
1702
|
+
*
|
|
1703
|
+
* Deleted outright. An empty week now falls through to `hasChartData` and
|
|
1704
|
+
* renders the empty state, a failed one renders the error state, and the Word
|
|
1705
|
+
* export's chart table is already gated on the same `hasChartData`.
|
|
1706
|
+
*/
|
|
1707
|
+
const weeklyActivity = weeklyActivityReq.value ?? [];
|
|
1625
1708
|
|
|
1626
1709
|
// `weekly-activity` returns each interval twice over: `openWorkOrder` and
|
|
1627
1710
|
// `workOrder` are the same not-yet-completed count, `closedWorkOrder` and
|
|
@@ -1760,9 +1843,21 @@ const defaultCardValues: TDashboardCardValue[] = [
|
|
|
1760
1843
|
{
|
|
1761
1844
|
key: "supplyAlert",
|
|
1762
1845
|
periodKey: "period",
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
1846
|
+
/**
|
|
1847
|
+
* IT IS NOT AN ALERT, SO IT NO LONGER SAYS ALERT.
|
|
1848
|
+
*
|
|
1849
|
+
* The server returns the three lowest-quantity active supplies and NO
|
|
1850
|
+
* threshold of any kind (new-dashboard.repo.ts) - so 900 gloves appeared
|
|
1851
|
+
* under a red "SUPPLY ALERT" heading with a warning triangle, next to
|
|
1852
|
+
* items that genuinely were low, and nothing distinguished them. The title,
|
|
1853
|
+
* the icon and the tone now describe what the data is.
|
|
1854
|
+
*
|
|
1855
|
+
* A real low-stock alert needs a per-supply reorder level the server does
|
|
1856
|
+
* not hold. That is backend work and is deliberately NOT invented here.
|
|
1857
|
+
*/
|
|
1858
|
+
title: "Lowest Stock",
|
|
1859
|
+
icon: "mdi-package-variant",
|
|
1860
|
+
color: KPI_TONES.info.color,
|
|
1766
1861
|
},
|
|
1767
1862
|
];
|
|
1768
1863
|
|
|
@@ -1837,12 +1932,16 @@ const resolvedCardValues = computed(() => {
|
|
|
1837
1932
|
|
|
1838
1933
|
// ─── Widget Keys by Mode ──────────────────────────────────────────────────────
|
|
1839
1934
|
|
|
1935
|
+
// "recentActivity" was in this list and in the Customize dialog, and NOTHING
|
|
1936
|
+
// renders it - `isWidgetVisible("recentActivity")` appears nowhere in the
|
|
1937
|
+
// template. Five apps offered a switch that did nothing whichever way it was
|
|
1938
|
+
// set. Removed rather than built: there is no Recent Activity panel anywhere in
|
|
1939
|
+
// the estate to connect it to, and inventing one is a new feature, not a fix.
|
|
1840
1940
|
const defaultExtraWidgetKeys = [
|
|
1841
1941
|
"activity",
|
|
1842
1942
|
"taskSchedule",
|
|
1843
1943
|
"staffStatus",
|
|
1844
1944
|
"feedbacks",
|
|
1845
|
-
"recentActivity",
|
|
1846
1945
|
];
|
|
1847
1946
|
|
|
1848
1947
|
const propertyDefaultWidgetKeys = [
|
|
@@ -1869,6 +1968,36 @@ const securityDefaultWidgetKeys = [
|
|
|
1869
1968
|
"feedbacks",
|
|
1870
1969
|
];
|
|
1871
1970
|
|
|
1971
|
+
/**
|
|
1972
|
+
* THE CUSTOMISE CHOICE, REMEMBERED.
|
|
1973
|
+
*
|
|
1974
|
+
* Hiding a widget worked and then forgot: nothing was written anywhere, so a
|
|
1975
|
+
* reload brought every panel back and the dialog was decoration.
|
|
1976
|
+
*
|
|
1977
|
+
* A COOKIE, and specifically the layer's own `cookieConfig` - the same
|
|
1978
|
+
* mechanism `sid`, `user`, `landing-page` and the light/dark preference already
|
|
1979
|
+
* travel on (see `composables/useThemePreference.ts` for the full reasoning).
|
|
1980
|
+
* localStorage would be per-origin, and this is one component shipped into
|
|
1981
|
+
* seven applications on seven hostnames. No new storage mechanism, and no
|
|
1982
|
+
* invented endpoint - there is no save API on the server for this.
|
|
1983
|
+
*
|
|
1984
|
+
* One cookie per dashboard MODE, because the three modes have different widget
|
|
1985
|
+
* sets and a security member's choice must not decide what a hygiene member
|
|
1986
|
+
* sees.
|
|
1987
|
+
*/
|
|
1988
|
+
const widgetPrefsCookie = useCookie<string[] | null>(
|
|
1989
|
+
`dashboard-widgets-${
|
|
1990
|
+
isPropertyMode.value
|
|
1991
|
+
? "property"
|
|
1992
|
+
: isSecurityMode.value
|
|
1993
|
+
? "security"
|
|
1994
|
+
: "service"
|
|
1995
|
+
}`,
|
|
1996
|
+
runtimeConfig.public.cookieConfig as CookieOptions<string[] | null> & {
|
|
1997
|
+
readonly?: false;
|
|
1998
|
+
}
|
|
1999
|
+
);
|
|
2000
|
+
|
|
1872
2001
|
const defaultVisibleWidgetKeys = computed(() => {
|
|
1873
2002
|
if (props.extraWidgetKeys && props.extraWidgetKeys.length > 0) {
|
|
1874
2003
|
return [
|
|
@@ -1889,8 +2018,16 @@ watch(
|
|
|
1889
2018
|
(keys) => {
|
|
1890
2019
|
const nextKeys = [...keys];
|
|
1891
2020
|
if (!visibleWidgetKeys.value.length) {
|
|
1892
|
-
|
|
1893
|
-
|
|
2021
|
+
// A remembered choice wins on first paint, filtered against the keys this
|
|
2022
|
+
// mode actually has so a stale cookie cannot resurrect a removed widget.
|
|
2023
|
+
// `Array.isArray` rather than a length check: "the user hid everything"
|
|
2024
|
+
// and "there is no cookie" are different answers.
|
|
2025
|
+
const remembered = Array.isArray(widgetPrefsCookie.value)
|
|
2026
|
+
? widgetPrefsCookie.value.filter((k) => nextKeys.includes(k))
|
|
2027
|
+
: null;
|
|
2028
|
+
const initial = remembered ?? nextKeys;
|
|
2029
|
+
visibleWidgetKeys.value = initial;
|
|
2030
|
+
draftVisibleWidgetKeys.value = [...initial];
|
|
1894
2031
|
return;
|
|
1895
2032
|
}
|
|
1896
2033
|
visibleWidgetKeys.value = visibleWidgetKeys.value.filter((k) =>
|
|
@@ -2037,11 +2174,6 @@ const customizeWidgets = computed(() => {
|
|
|
2037
2174
|
title: "Feedbacks",
|
|
2038
2175
|
description: "Show recent feedback and resolution state.",
|
|
2039
2176
|
},
|
|
2040
|
-
{
|
|
2041
|
-
key: "recentActivity",
|
|
2042
|
-
title: "Recent Activity",
|
|
2043
|
-
description: "Latest updates across the service.",
|
|
2044
|
-
},
|
|
2045
2177
|
];
|
|
2046
2178
|
});
|
|
2047
2179
|
|
|
@@ -2285,7 +2417,7 @@ function getSecurityKpiCards() {
|
|
|
2285
2417
|
];
|
|
2286
2418
|
}
|
|
2287
2419
|
|
|
2288
|
-
const
|
|
2420
|
+
const rawKpiCards = computed(() => {
|
|
2289
2421
|
if (isPropertyMode.value) return getPropertyKpiCards();
|
|
2290
2422
|
if (isSecurityMode.value) return getSecurityKpiCards();
|
|
2291
2423
|
|
|
@@ -2312,6 +2444,22 @@ const kpiCards = computed(() => {
|
|
|
2312
2444
|
});
|
|
2313
2445
|
});
|
|
2314
2446
|
|
|
2447
|
+
/**
|
|
2448
|
+
* A tile whose request failed shows an em dash and says so, never a zero.
|
|
2449
|
+
* `percentage: NaN` is what suppresses the pill - `kpiChipText()` returns ""
|
|
2450
|
+
* for anything non-finite - and `raw: {}` empties the supply list with it.
|
|
2451
|
+
*/
|
|
2452
|
+
const kpiCards = computed(() => {
|
|
2453
|
+
if (!metricsFailed.value) return rawKpiCards.value;
|
|
2454
|
+
return rawKpiCards.value.map((card) => ({
|
|
2455
|
+
...card,
|
|
2456
|
+
value: "—",
|
|
2457
|
+
percentage: NaN,
|
|
2458
|
+
meta: LOAD_FAILED_TEXT,
|
|
2459
|
+
raw: {} as any,
|
|
2460
|
+
}));
|
|
2461
|
+
});
|
|
2462
|
+
|
|
2315
2463
|
const visibleKpiCards = computed(() =>
|
|
2316
2464
|
kpiCards.value.filter((card) => isWidgetVisible(card.key))
|
|
2317
2465
|
);
|
|
@@ -2534,42 +2682,15 @@ const todayAttentions = computed(() =>
|
|
|
2534
2682
|
* is shared by every chip in the estate - or ruling that a chart series is not
|
|
2535
2683
|
* a status chip. Both are design decisions, so neither is taken here.
|
|
2536
2684
|
*/
|
|
2537
|
-
const workOrderStatus = computed(() =>
|
|
2538
|
-
|
|
2539
|
-
|
|
2540
|
-
|
|
2541
|
-
|
|
2542
|
-
|
|
2543
|
-
|
|
2544
|
-
|
|
2545
|
-
|
|
2546
|
-
const pending =
|
|
2547
|
-
src.pending !== undefined
|
|
2548
|
-
? Number(src.pending)
|
|
2549
|
-
: Math.max(0, Number(src.count ?? 0) - inProgress);
|
|
2550
|
-
const max = Math.max(completed, inProgress, pending, 1);
|
|
2551
|
-
|
|
2552
|
-
return [
|
|
2553
|
-
{
|
|
2554
|
-
label: "Completed",
|
|
2555
|
-
value: completed,
|
|
2556
|
-
percent: (completed / max) * 100,
|
|
2557
|
-
color: "var(--ok)",
|
|
2558
|
-
},
|
|
2559
|
-
{
|
|
2560
|
-
label: "In Progress",
|
|
2561
|
-
value: inProgress,
|
|
2562
|
-
percent: (inProgress / max) * 100,
|
|
2563
|
-
color: "var(--err)",
|
|
2564
|
-
},
|
|
2565
|
-
{
|
|
2566
|
-
label: "Pending",
|
|
2567
|
-
value: pending,
|
|
2568
|
-
percent: (pending / max) * 100,
|
|
2569
|
-
color: "var(--warn)",
|
|
2570
|
-
},
|
|
2571
|
-
];
|
|
2572
|
-
});
|
|
2685
|
+
const workOrderStatus = computed(() =>
|
|
2686
|
+
// The arithmetic and the "no breakdown, no bars" rule live in
|
|
2687
|
+
// `utils/dashboard.ts` so they have a test; the tone -> token mapping stays
|
|
2688
|
+
// here because it is this template's colour question, not the server's.
|
|
2689
|
+
buildWorkOrderStatus(finalDashboardData.value?.workOrderStatus).map((row) => ({
|
|
2690
|
+
...row,
|
|
2691
|
+
color: `var(--${row.tone})`,
|
|
2692
|
+
}))
|
|
2693
|
+
);
|
|
2573
2694
|
|
|
2574
2695
|
// Security-mode lists
|
|
2575
2696
|
const activePatrolItems = computed<any[]>(
|
|
@@ -2762,7 +2883,21 @@ onUnmounted(() => {
|
|
|
2762
2883
|
watch(selectedRange, (period) => {
|
|
2763
2884
|
if (props.serviceType) {
|
|
2764
2885
|
const apiPeriod = periodValue[period];
|
|
2765
|
-
if (apiPeriod)
|
|
2886
|
+
if (apiPeriod) {
|
|
2887
|
+
currentPeriod.value = apiPeriod;
|
|
2888
|
+
/**
|
|
2889
|
+
* The component already READ `?period=` on mount and never wrote it
|
|
2890
|
+
* back, so a refresh - or a link sent to a colleague - silently returned
|
|
2891
|
+
* to Today while the button still said This Month. Writing it closes
|
|
2892
|
+
* both. `replace`, not `push`: changing the range is not a navigation
|
|
2893
|
+
* step the Back button should have to walk through.
|
|
2894
|
+
*
|
|
2895
|
+
* What each range MEANS is untouched here.
|
|
2896
|
+
*/
|
|
2897
|
+
if (route.query.period !== apiPeriod) {
|
|
2898
|
+
router.replace({ query: { ...route.query, period: apiPeriod } });
|
|
2899
|
+
}
|
|
2900
|
+
}
|
|
2766
2901
|
}
|
|
2767
2902
|
emit("updatePeriod", { key: "__all__", period });
|
|
2768
2903
|
});
|
|
@@ -3341,6 +3476,9 @@ function resetCustomizeDraft() {
|
|
|
3341
3476
|
|
|
3342
3477
|
function submitCustomize() {
|
|
3343
3478
|
visibleWidgetKeys.value = [...draftVisibleWidgetKeys.value];
|
|
3479
|
+
// The write is the whole point - see `widgetPrefsCookie`. Saving on Submit
|
|
3480
|
+
// rather than on every switch keeps Cancel meaning cancel.
|
|
3481
|
+
widgetPrefsCookie.value = [...draftVisibleWidgetKeys.value];
|
|
3344
3482
|
widgetSearch.value = "";
|
|
3345
3483
|
customizeDialog.value = false;
|
|
3346
3484
|
}
|
|
@@ -3440,6 +3578,13 @@ async function exportDashboard(format: "PDF" | "Word") {
|
|
|
3440
3578
|
<body>
|
|
3441
3579
|
<h1>${props.title || dashboardTitle.value}</h1>
|
|
3442
3580
|
<div class="subtitle">Site: ${siteName} | Period: ${periodLabel}</div>
|
|
3581
|
+
${
|
|
3582
|
+
failedWidgetLabels.value.length
|
|
3583
|
+
? `<div class="subtitle" style="color:#b73535;">Incomplete report: ${failedWidgetLabels.value.join(
|
|
3584
|
+
", "
|
|
3585
|
+
)} could not be loaded. Any figure shown as — is missing, not zero.</div>`
|
|
3586
|
+
: ""
|
|
3587
|
+
}
|
|
3443
3588
|
`;
|
|
3444
3589
|
|
|
3445
3590
|
if (visibleKpiCards.value && visibleKpiCards.value.length > 0) {
|
|
@@ -3452,6 +3597,9 @@ async function exportDashboard(format: "PDF" | "Word") {
|
|
|
3452
3597
|
let kpiValueHtml = `<div class="kpi-value">${card.value}</div>`;
|
|
3453
3598
|
if (card.key === "supplyAlert" && Array.isArray(card.raw?.items)) {
|
|
3454
3599
|
kpiValueHtml = '<div style="font-size: 9pt; margin-top: 4px;">';
|
|
3600
|
+
if (!card.raw.items.length) {
|
|
3601
|
+
kpiValueHtml += "<div>No supplies recorded</div>";
|
|
3602
|
+
}
|
|
3455
3603
|
card.raw.items.forEach((item: any) => {
|
|
3456
3604
|
kpiValueHtml += `<div><strong>${item.name}</strong>: Balance ${item.qty}</div>`;
|
|
3457
3605
|
});
|
|
@@ -89,6 +89,8 @@
|
|
|
89
89
|
</template>
|
|
90
90
|
|
|
91
91
|
<script setup lang="ts">
|
|
92
|
+
import { moduleNameFromRoute } from "../../utils/breadcrumb";
|
|
93
|
+
|
|
92
94
|
const props = defineProps({
|
|
93
95
|
hideNavIcon: {
|
|
94
96
|
type: Boolean,
|
|
@@ -142,39 +144,12 @@ const siteName = computed(() =>
|
|
|
142
144
|
const route = useRoute();
|
|
143
145
|
|
|
144
146
|
/**
|
|
145
|
-
* The
|
|
146
|
-
*
|
|
147
|
-
*
|
|
148
|
-
*
|
|
149
|
-
* status route (`...-members-status`) both name their module rather than their
|
|
150
|
-
* argument.
|
|
147
|
+
* The page's name comes off the route name, which the applications build from
|
|
148
|
+
* their own folder structure: `org-site-work-orders` is Work Orders. The rule
|
|
149
|
+
* itself lives in `utils/breadcrumb.ts`, where it is unit-tested against every
|
|
150
|
+
* route shape the eleven applications actually have.
|
|
151
151
|
*/
|
|
152
|
-
const
|
|
153
|
-
cctv: "CCTV",
|
|
154
|
-
hid: "HID",
|
|
155
|
-
dob: "DOB",
|
|
156
|
-
anpr: "ANPR",
|
|
157
|
-
nfc: "NFC",
|
|
158
|
-
qr: "QR",
|
|
159
|
-
soa: "SOA",
|
|
160
|
-
sp: "SP",
|
|
161
|
-
id: "ID",
|
|
162
|
-
};
|
|
163
|
-
|
|
164
|
-
const moduleName = computed(() => {
|
|
165
|
-
const segments = String(route.name ?? "").split("-").filter(Boolean);
|
|
166
|
-
const params = new Set(Object.keys(route.params ?? {}));
|
|
167
|
-
|
|
168
|
-
let i = 0;
|
|
169
|
-
while (i < segments.length && params.has(segments[i])) i++;
|
|
170
|
-
|
|
171
|
-
const words: string[] = [];
|
|
172
|
-
while (i < segments.length && !params.has(segments[i])) words.push(segments[i++]);
|
|
173
|
-
|
|
174
|
-
return words
|
|
175
|
-
.map((w) => ACRONYMS[w] ?? w.charAt(0).toUpperCase() + w.slice(1))
|
|
176
|
-
.join(" ");
|
|
177
|
-
});
|
|
152
|
+
const moduleName = computed(() => moduleNameFromRoute(route));
|
|
178
153
|
|
|
179
154
|
const profile = computed(() => {
|
|
180
155
|
return `/api/public/${currentUser.value?.profile}`;
|
|
@@ -35,7 +35,9 @@
|
|
|
35
35
|
</span>
|
|
36
36
|
<v-text-field
|
|
37
37
|
v-model="search"
|
|
38
|
-
@keydown.enter="
|
|
38
|
+
@keydown.enter="triggerSearch"
|
|
39
|
+
clearable
|
|
40
|
+
@click:clear="clearSearch"
|
|
39
41
|
density="compact"
|
|
40
42
|
width="100%"
|
|
41
43
|
hide-details
|
|
@@ -178,6 +180,33 @@ watchEffect(() => {
|
|
|
178
180
|
}
|
|
179
181
|
});
|
|
180
182
|
|
|
183
|
+
let searchDebounce: ReturnType<typeof setTimeout> | null = null;
|
|
184
|
+
let skipNextWatch = false;
|
|
185
|
+
|
|
186
|
+
const triggerSearch = () => {
|
|
187
|
+
if (searchDebounce) clearTimeout(searchDebounce);
|
|
188
|
+
emit("search");
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
const clearSearch = () => {
|
|
192
|
+
skipNextWatch = true;
|
|
193
|
+
search.value = "";
|
|
194
|
+
triggerSearch();
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
watch(search, () => {
|
|
198
|
+
if (skipNextWatch) {
|
|
199
|
+
skipNextWatch = false;
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
if (searchDebounce) clearTimeout(searchDebounce);
|
|
203
|
+
searchDebounce = setTimeout(triggerSearch, 1000);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
onBeforeUnmount(() => {
|
|
207
|
+
if (searchDebounce) clearTimeout(searchDebounce);
|
|
208
|
+
});
|
|
209
|
+
|
|
181
210
|
const selectItem = (value: string) => {
|
|
182
211
|
selected.value = value;
|
|
183
212
|
menu.value = false;
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "@7365admin1/layer-common",
|
|
3
3
|
"license": "MIT",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"version": "3.2.2-staging.
|
|
5
|
+
"version": "3.2.2-staging.188",
|
|
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.",
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { test } from "node:test";
|
|
3
|
+
|
|
4
|
+
import { moduleNameFromRoute } from "./breadcrumb.ts";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Real routes, not invented ones: name, path and parameters exactly as
|
|
8
|
+
* vue-router hands them to the bar in each application.
|
|
9
|
+
*/
|
|
10
|
+
const ID = "68b0c1d2e3f4a5b6c7d8e9f0";
|
|
11
|
+
|
|
12
|
+
test("a site application names the page, not its parameters", () => {
|
|
13
|
+
assert.equal(
|
|
14
|
+
moduleNameFromRoute({
|
|
15
|
+
name: "org-site-work-orders",
|
|
16
|
+
path: `/${ID}/${ID}/work-orders`,
|
|
17
|
+
params: { org: ID, site: ID },
|
|
18
|
+
}),
|
|
19
|
+
"Work Orders"
|
|
20
|
+
);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("a detail route names its module, not its argument", () => {
|
|
24
|
+
assert.equal(
|
|
25
|
+
moduleNameFromRoute({
|
|
26
|
+
name: "org-site-feedbacks-id",
|
|
27
|
+
path: `/${ID}/${ID}/feedbacks/${ID}`,
|
|
28
|
+
params: { org: ID, site: ID, id: ID },
|
|
29
|
+
}),
|
|
30
|
+
"Feedbacks"
|
|
31
|
+
);
|
|
32
|
+
assert.equal(
|
|
33
|
+
moduleNameFromRoute({
|
|
34
|
+
name: "org-site-members-status",
|
|
35
|
+
path: `/${ID}/${ID}/members/pending`,
|
|
36
|
+
params: { org: ID, site: ID, status: "pending" },
|
|
37
|
+
}),
|
|
38
|
+
"Members"
|
|
39
|
+
);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("a fixed folder in front of the context parameter is not the page", () => {
|
|
43
|
+
// THE DEFECT: every screen of the organization application read "Org".
|
|
44
|
+
assert.equal(
|
|
45
|
+
moduleNameFromRoute({
|
|
46
|
+
name: "org-organization-marketplace",
|
|
47
|
+
path: `/org/${ID}/marketplace`,
|
|
48
|
+
params: { organization: ID },
|
|
49
|
+
}),
|
|
50
|
+
"Marketplace"
|
|
51
|
+
);
|
|
52
|
+
assert.equal(
|
|
53
|
+
moduleNameFromRoute({
|
|
54
|
+
name: "org-organization-payment-methods-linked",
|
|
55
|
+
path: `/org/${ID}/payment-methods/linked`,
|
|
56
|
+
params: { organization: ID },
|
|
57
|
+
}),
|
|
58
|
+
"Payment Methods Linked"
|
|
59
|
+
);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("a literal second segment is NOT a skipped context", () => {
|
|
63
|
+
// `/organizations/status/:status` also begins with a literal folder, but
|
|
64
|
+
// "Organizations" is the page here - only the THIRD segment is a parameter.
|
|
65
|
+
assert.equal(
|
|
66
|
+
moduleNameFromRoute({
|
|
67
|
+
name: "organizations-status-status",
|
|
68
|
+
path: "/organizations/status/pending",
|
|
69
|
+
params: { status: "pending" },
|
|
70
|
+
}),
|
|
71
|
+
"Organizations"
|
|
72
|
+
);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("a route with no parameters is left alone", () => {
|
|
76
|
+
assert.equal(
|
|
77
|
+
moduleNameFromRoute({
|
|
78
|
+
name: "super-admin-client-list",
|
|
79
|
+
path: "/super-admin/client-list",
|
|
80
|
+
params: {},
|
|
81
|
+
}),
|
|
82
|
+
"Super Admin Client List"
|
|
83
|
+
);
|
|
84
|
+
assert.equal(
|
|
85
|
+
moduleNameFromRoute({ name: "personal-info-name", path: "/personal-info/name", params: {} }),
|
|
86
|
+
"Personal Info Name"
|
|
87
|
+
);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("acronyms keep their case", () => {
|
|
91
|
+
assert.equal(
|
|
92
|
+
moduleNameFromRoute({
|
|
93
|
+
name: "org-site-virtual-patrol-cctv",
|
|
94
|
+
path: `/${ID}/${ID}/virtual-patrol/cctv`,
|
|
95
|
+
params: { org: ID, site: ID },
|
|
96
|
+
}),
|
|
97
|
+
"Virtual Patrol CCTV"
|
|
98
|
+
);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("a context-only route has no page half", () => {
|
|
102
|
+
// The organization dashboard: the bar shows the organization's name alone.
|
|
103
|
+
assert.equal(
|
|
104
|
+
moduleNameFromRoute({ name: "org-organization", path: `/org/${ID}`, params: { organization: ID } }),
|
|
105
|
+
""
|
|
106
|
+
);
|
|
107
|
+
assert.equal(moduleNameFromRoute({ name: "", path: "/", params: {} }), "");
|
|
108
|
+
});
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE TOP BAR'S BREADCRUMB, PAGE HALF.
|
|
3
|
+
*
|
|
4
|
+
* The bar draws `Context / Page`. The page half is derived from the route the
|
|
5
|
+
* router is already on - nothing is fetched and nothing is configured per app,
|
|
6
|
+
* because the applications build their route names from their own folder
|
|
7
|
+
* structure: `org-site-work-orders` is Work Orders.
|
|
8
|
+
*
|
|
9
|
+
* Lives here rather than in the component so the rule is testable: it is the
|
|
10
|
+
* one piece of the bar with real branching, and it named a whole application's
|
|
11
|
+
* screens wrongly for want of a case.
|
|
12
|
+
*/
|
|
13
|
+
const ACRONYMS: Record<string, string> = {
|
|
14
|
+
cctv: "CCTV",
|
|
15
|
+
hid: "HID",
|
|
16
|
+
dob: "DOB",
|
|
17
|
+
anpr: "ANPR",
|
|
18
|
+
nfc: "NFC",
|
|
19
|
+
qr: "QR",
|
|
20
|
+
soa: "SOA",
|
|
21
|
+
sp: "SP",
|
|
22
|
+
id: "ID",
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export function moduleNameFromRoute(route: {
|
|
26
|
+
name?: unknown;
|
|
27
|
+
path?: unknown;
|
|
28
|
+
params?: Record<string, unknown> | null;
|
|
29
|
+
}): string {
|
|
30
|
+
const segments = String(route.name ?? "").split("-").filter(Boolean);
|
|
31
|
+
const params = new Set(Object.keys(route.params ?? {}));
|
|
32
|
+
|
|
33
|
+
let i = 0;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* A FIXED FOLDER IN FRONT OF THE CONTEXT PARAMETER IS NOT THE PAGE.
|
|
37
|
+
*
|
|
38
|
+
* The site applications are `/:org/:site/...`, so skipping the leading
|
|
39
|
+
* parameters was enough. The organization application is
|
|
40
|
+
* `/org/:organization/...` - a literal folder first - and the route NAME
|
|
41
|
+
* cannot tell that "org" is a folder and not a parameter, so every one of
|
|
42
|
+
* that application's screens named itself "Org" instead of the page it is.
|
|
43
|
+
*
|
|
44
|
+
* The PATH can tell: its first segment is that same word, and the segment
|
|
45
|
+
* after it is a parameter's VALUE. Checked against the path rather than
|
|
46
|
+
* assumed, because `/organizations/status/:status` also begins with a
|
|
47
|
+
* literal - and there "Organizations Status" is the right page name, its
|
|
48
|
+
* second path segment being the folder `status` and not the parameter's
|
|
49
|
+
* value.
|
|
50
|
+
*/
|
|
51
|
+
const values = new Set(
|
|
52
|
+
Object.values(route.params ?? {})
|
|
53
|
+
.flat()
|
|
54
|
+
.map((v) => String(v))
|
|
55
|
+
.filter(Boolean)
|
|
56
|
+
);
|
|
57
|
+
const pathParts = String(route.path ?? "").split("/").filter(Boolean);
|
|
58
|
+
|
|
59
|
+
if (
|
|
60
|
+
segments.length > 1 &&
|
|
61
|
+
!params.has(segments[0]) &&
|
|
62
|
+
pathParts[0] === segments[0] &&
|
|
63
|
+
values.has(String(pathParts[1] ?? ""))
|
|
64
|
+
) {
|
|
65
|
+
i++;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Leading parameter segments (`org`, `site`) are skipped and the name stops
|
|
70
|
+
* at the first one that follows, so a detail route (`...-feedbacks-id`) and
|
|
71
|
+
* a status route (`...-members-status`) both name their module rather than
|
|
72
|
+
* their argument.
|
|
73
|
+
*/
|
|
74
|
+
while (i < segments.length && params.has(segments[i])) i++;
|
|
75
|
+
|
|
76
|
+
const words: string[] = [];
|
|
77
|
+
while (i < segments.length && !params.has(segments[i])) words.push(segments[i++]);
|
|
78
|
+
|
|
79
|
+
return words
|
|
80
|
+
.map((w) => ACRONYMS[w] ?? w.charAt(0).toUpperCase() + w.slice(1))
|
|
81
|
+
.join(" ");
|
|
82
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { test } from "node:test";
|
|
3
|
+
|
|
4
|
+
import { buildWorkOrderStatus } from "./dashboard.ts";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* THE DEFECT THIS FILE EXISTS FOR.
|
|
8
|
+
*
|
|
9
|
+
* The Property Management "Work Order Status" panel read
|
|
10
|
+
* `workOrderStatusSummary` - a key no endpoint returns - and fell through to
|
|
11
|
+
* the `openWorkOrder` KPI metric, which carries no `completed` field. So
|
|
12
|
+
* Completed read 0 on every site on every day, and Pending was silently
|
|
13
|
+
* recomputed as (open work orders - in progress) instead of read.
|
|
14
|
+
*
|
|
15
|
+
* The shape below is the one `new-dashboard.repo.ts` actually sends.
|
|
16
|
+
*/
|
|
17
|
+
test("the server's breakdown is read, not recomputed", () => {
|
|
18
|
+
const rows = buildWorkOrderStatus({
|
|
19
|
+
pending: 20,
|
|
20
|
+
inProgress: 12,
|
|
21
|
+
completed: 55,
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
assert.deepEqual(
|
|
25
|
+
rows.map((r) => [r.label, r.value]),
|
|
26
|
+
[
|
|
27
|
+
["Completed", 55],
|
|
28
|
+
["In Progress", 12],
|
|
29
|
+
["Pending", 20],
|
|
30
|
+
]
|
|
31
|
+
);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The exact regression. Before the fix this same input drew
|
|
36
|
+
* Completed 0 / In Progress 12 / Pending 28.
|
|
37
|
+
*/
|
|
38
|
+
test("Completed is never zero when the server says it is not", () => {
|
|
39
|
+
const completed = buildWorkOrderStatus({
|
|
40
|
+
pending: 20,
|
|
41
|
+
inProgress: 12,
|
|
42
|
+
completed: 55,
|
|
43
|
+
}).find((r) => r.label === "Completed");
|
|
44
|
+
|
|
45
|
+
assert.equal(completed?.value, 55);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The bars are share-of-largest-bucket, so the biggest is always full width
|
|
50
|
+
* and the rest are proportional to it. This is the widths the panel draws.
|
|
51
|
+
*/
|
|
52
|
+
test("each bar is its share of the largest bucket", () => {
|
|
53
|
+
const rows = buildWorkOrderStatus({ pending: 25, inProgress: 50, completed: 100 });
|
|
54
|
+
|
|
55
|
+
assert.deepEqual(
|
|
56
|
+
rows.map((r) => r.percent),
|
|
57
|
+
[100, 50, 25]
|
|
58
|
+
);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
/** Three empty buckets must draw three empty bars, not divide by zero. */
|
|
62
|
+
test("an all-zero breakdown does not divide by zero", () => {
|
|
63
|
+
const rows = buildWorkOrderStatus({ pending: 0, inProgress: 0, completed: 0 });
|
|
64
|
+
|
|
65
|
+
assert.equal(rows.length, 3);
|
|
66
|
+
for (const row of rows) {
|
|
67
|
+
assert.equal(row.value, 0);
|
|
68
|
+
assert.equal(row.percent, 0, row.label);
|
|
69
|
+
assert.ok(Number.isFinite(row.percent), row.label);
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
/** A partial payload is read for what it has and zeroed for what it does not. */
|
|
74
|
+
test("missing buckets read as zero rather than undefined", () => {
|
|
75
|
+
const rows = buildWorkOrderStatus({ completed: 7 });
|
|
76
|
+
|
|
77
|
+
assert.deepEqual(
|
|
78
|
+
rows.map((r) => r.value),
|
|
79
|
+
[7, 0, 0]
|
|
80
|
+
);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* NO FALLBACK, DELIBERATELY. When the breakdown is absent there is nothing
|
|
85
|
+
* honest to draw, so the panel must show its empty state - not bars built out
|
|
86
|
+
* of a different metric. An empty array is what triggers that.
|
|
87
|
+
*/
|
|
88
|
+
test("no breakdown means no bars, never invented ones", () => {
|
|
89
|
+
assert.deepEqual(buildWorkOrderStatus(undefined), []);
|
|
90
|
+
assert.deepEqual(buildWorkOrderStatus(null), []);
|
|
91
|
+
assert.deepEqual(buildWorkOrderStatus(42 as never), []);
|
|
92
|
+
assert.deepEqual(buildWorkOrderStatus("55" as never), []);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* The tone is a token NAME, not a colour. The template writes `var(--<tone>)`,
|
|
97
|
+
* so a tone that is not a real token silently paints nothing.
|
|
98
|
+
*/
|
|
99
|
+
test("every row carries a real design token name", () => {
|
|
100
|
+
const rows = buildWorkOrderStatus({ pending: 1, inProgress: 2, completed: 3 });
|
|
101
|
+
|
|
102
|
+
assert.deepEqual(
|
|
103
|
+
rows.map((r) => r.tone),
|
|
104
|
+
["ok", "err", "warn"]
|
|
105
|
+
);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* The buckets are the whole of the server's status report - it folds every
|
|
110
|
+
* unrecognised status into `pending` rather than dropping it - so nothing the
|
|
111
|
+
* server counted goes missing on the way to the screen.
|
|
112
|
+
*/
|
|
113
|
+
test("the three buckets account for the whole breakdown", () => {
|
|
114
|
+
const src = { pending: 20, inProgress: 12, completed: 55 };
|
|
115
|
+
const total = buildWorkOrderStatus(src).reduce((sum, r) => sum + r.value, 0);
|
|
116
|
+
|
|
117
|
+
assert.equal(total, src.pending + src.inProgress + src.completed);
|
|
118
|
+
});
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dashboard arithmetic that is worth testing on its own.
|
|
3
|
+
*
|
|
4
|
+
* `DashboardMain.vue` is 4,800 lines of template and wiring, and none of it is
|
|
5
|
+
* reachable from `node --test`. The one piece with real arithmetic in it - the
|
|
6
|
+
* work-order status breakdown - lives here instead, so the defect that made
|
|
7
|
+
* "Completed" read zero on every Property Management dashboard has a test that
|
|
8
|
+
* fails if it ever comes back.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export type TWorkOrderStatusSource = {
|
|
12
|
+
pending?: number;
|
|
13
|
+
inProgress?: number;
|
|
14
|
+
completed?: number;
|
|
15
|
+
} | null | undefined;
|
|
16
|
+
|
|
17
|
+
export type TWorkOrderStatusRow = {
|
|
18
|
+
label: string;
|
|
19
|
+
value: number;
|
|
20
|
+
/** Share of the largest bucket, 0-100 - the bar's width. */
|
|
21
|
+
percent: number;
|
|
22
|
+
/** A design token name, not a colour: the caller writes `var(--<tone>)`. */
|
|
23
|
+
tone: "ok" | "err" | "warn";
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The three status bars, from the breakdown the server sends as
|
|
28
|
+
* `workOrderStatus` (new-dashboard.repo.ts, property branch).
|
|
29
|
+
*
|
|
30
|
+
* NO FALLBACK. The screen used to read `workOrderStatusSummary` - a key no
|
|
31
|
+
* endpoint returns - and fall through to the `openWorkOrder` KPI metric, which
|
|
32
|
+
* carries no `completed` field: Completed was therefore ALWAYS 0 and Pending
|
|
33
|
+
* was silently recomputed as (open work orders - in progress) instead of read.
|
|
34
|
+
* Without the real breakdown there is nothing honest to draw, so this returns
|
|
35
|
+
* an empty list and the panel shows its empty state.
|
|
36
|
+
*/
|
|
37
|
+
export function buildWorkOrderStatus(
|
|
38
|
+
src: TWorkOrderStatusSource
|
|
39
|
+
): TWorkOrderStatusRow[] {
|
|
40
|
+
if (!src || typeof src !== "object") return [];
|
|
41
|
+
|
|
42
|
+
const completed = Number(src.completed ?? 0);
|
|
43
|
+
const inProgress = Number(src.inProgress ?? 0);
|
|
44
|
+
const pending = Number(src.pending ?? 0);
|
|
45
|
+
// `1` floors the divisor: three empty buckets must not divide by zero.
|
|
46
|
+
const max = Math.max(completed, inProgress, pending, 1);
|
|
47
|
+
|
|
48
|
+
return [
|
|
49
|
+
{ label: "Completed", value: completed, percent: (completed / max) * 100, tone: "ok" },
|
|
50
|
+
{ label: "In Progress", value: inProgress, percent: (inProgress / max) * 100, tone: "err" },
|
|
51
|
+
{ label: "Pending", value: pending, percent: (pending / max) * 100, tone: "warn" },
|
|
52
|
+
];
|
|
53
|
+
}
|
package/utils/theme-aa-ledger.ts
CHANGED
|
@@ -76,9 +76,15 @@ export const LIGHT_PAIRS: Array<{
|
|
|
76
76
|
bg: string;
|
|
77
77
|
need: number;
|
|
78
78
|
}> = [
|
|
79
|
-
{ what: "muted on a card (
|
|
79
|
+
{ what: "muted on a card (KPI sub-lines, field labels)", fg: L.muted, bg: L.card, need: 4.5 },
|
|
80
80
|
{ what: "muted on the page background", fg: L.muted, bg: L.bg, need: 4.5 },
|
|
81
|
-
|
|
81
|
+
/*
|
|
82
|
+
* `muted on a table header band` used to sit here at 3.10:1. `.table-card
|
|
83
|
+
* thead th` now takes `--text2` (9.22:1 light / 9.57:1 dark), and nothing
|
|
84
|
+
* else paints `--muted` on a `--thead` band - `.table-card__group`'s label
|
|
85
|
+
* is Vuetify's `text-medium-emphasis`. The pair no longer occurs, so it is
|
|
86
|
+
* no longer carried as accepted debt.
|
|
87
|
+
*/
|
|
82
88
|
{ what: "muted on the sidebar", fg: L.muted, bg: L.sidebar, need: 4.5 },
|
|
83
89
|
{ what: "muted on a disabled control's hover fill", fg: L.muted, bg: hoverOnBg, need: 4.5 },
|
|
84
90
|
{ what: "ok label on its own chip", fg: L.ok, bg: chip.ok, need: 4.5 },
|
|
@@ -115,7 +121,7 @@ export const DARK_PAIRS = () => {
|
|
|
115
121
|
return [
|
|
116
122
|
{ what: "muted on a card", fg: D.muted, bg: D.card, need: 4.5 },
|
|
117
123
|
{ what: "muted on the page background", fg: D.muted, bg: D.bg, need: 4.5 },
|
|
118
|
-
{ what: "muted on a
|
|
124
|
+
{ what: "muted on a --thead band (the tint itself, no longer the header text)", fg: D.muted, bg: dthead, need: 4.5 },
|
|
119
125
|
{ what: "muted on the sidebar", fg: D.muted, bg: D.sidebar, need: 4.5 },
|
|
120
126
|
{ what: "text on a card", fg: D.text, bg: D.card, need: 4.5 },
|
|
121
127
|
{ what: "text2 on a card", fg: D.text2, bg: D.card, need: 4.5 },
|
package/utils/theme.test.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
2
3
|
import { test } from "node:test";
|
|
3
4
|
|
|
4
5
|
import { DARK_THEME, LIGHT_THEME, PALETTE, type TPalette } from "./theme.ts";
|
|
@@ -505,9 +506,8 @@ test("the light palette is the handoff's, value for value", () => {
|
|
|
505
506
|
*/
|
|
506
507
|
test("the light theme's sub-AA pairs measure exactly what the ledger says", () => {
|
|
507
508
|
const EXPECTED: Record<string, number> = {
|
|
508
|
-
"muted on a card (
|
|
509
|
+
"muted on a card (KPI sub-lines, field labels)": 3.24,
|
|
509
510
|
"muted on the page background": 2.99,
|
|
510
|
-
"muted on a table header band": 3.1,
|
|
511
511
|
"muted on the sidebar": 3.13,
|
|
512
512
|
"muted on a disabled control's hover fill": 2.71,
|
|
513
513
|
"ok label on its own chip": 4.57,
|
|
@@ -548,8 +548,8 @@ test("the light theme's sub-AA pairs measure exactly what the ledger says", () =
|
|
|
548
548
|
const failing = measured.filter((r) => !r.passes).length;
|
|
549
549
|
assert.equal(
|
|
550
550
|
failing,
|
|
551
|
-
|
|
552
|
-
`${failing} light pairs are below AA; the recorded, accepted count is
|
|
551
|
+
7,
|
|
552
|
+
`${failing} light pairs are below AA; the recorded, accepted count is 7. ` +
|
|
553
553
|
"If this moved, the owner's design-fidelity decision needs re-stating."
|
|
554
554
|
);
|
|
555
555
|
});
|
|
@@ -560,6 +560,90 @@ test("the dark theme has no sub-AA pairs at all", () => {
|
|
|
560
560
|
assert.deepEqual(bad, [], "dark mode was exact AND compliant - keep it that way");
|
|
561
561
|
});
|
|
562
562
|
|
|
563
|
+
/* ------------------------------------------------------------------ */
|
|
564
|
+
/* The two contrast fixes QA drove, asserted at the token level AND at */
|
|
565
|
+
/* the stylesheet that wires them. */
|
|
566
|
+
/* ------------------------------------------------------------------ */
|
|
567
|
+
|
|
568
|
+
/**
|
|
569
|
+
* A native `<select>`'s option list is painted by the browser. With no
|
|
570
|
+
* background of its own the option keeps the theme's ink over whatever the
|
|
571
|
+
* browser paints - white on Windows/Chrome - which is 1.14:1 in dark mode.
|
|
572
|
+
* The rule gives it `--card`/`--text`, the same pair the themed popover uses.
|
|
573
|
+
*/
|
|
574
|
+
test("a native option's own fill carries the theme ink in both themes", () => {
|
|
575
|
+
for (const [name, p] of [["light", PALETTE.light], ["dark", PALETTE.dark]] as const) {
|
|
576
|
+
assert.ok(
|
|
577
|
+
contrast(p.text, p.card) >= 4.5,
|
|
578
|
+
`${name}: option ink on the card fill is ${contrast(p.text, p.card).toFixed(2)}:1`
|
|
579
|
+
);
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
// What the defect measured, so the number stays on the record.
|
|
583
|
+
assert.equal(Number(contrast(PALETTE.dark.text, "#ffffff").toFixed(2)), 1.14);
|
|
584
|
+
});
|
|
585
|
+
|
|
586
|
+
/**
|
|
587
|
+
* The table header. `--muted` measured 3.10:1 on the header band in light -
|
|
588
|
+
* below AA and reported by two apps' QA. `--text2` clears AA in both themes
|
|
589
|
+
* while staying clearly below the primary ink the data rows use, so the
|
|
590
|
+
* header still reads as secondary to the data.
|
|
591
|
+
*/
|
|
592
|
+
test("the table header clears AA and still reads below the data", () => {
|
|
593
|
+
for (const [name, p, wash] of [
|
|
594
|
+
["light", PALETTE.light, "#14161a"],
|
|
595
|
+
["dark", PALETTE.dark, "#ffffff"],
|
|
596
|
+
] as const) {
|
|
597
|
+
const band = over(wash, p.card, 0.02);
|
|
598
|
+
const header = contrast(p.text2, band);
|
|
599
|
+
const data = contrast(p.text, band);
|
|
600
|
+
|
|
601
|
+
assert.ok(header >= 4.5, `${name}: header is ${header.toFixed(2)}:1, below AA`);
|
|
602
|
+
assert.ok(
|
|
603
|
+
header < data,
|
|
604
|
+
`${name}: the header (${header.toFixed(2)}:1) must stay below the data ` +
|
|
605
|
+
`(${data.toFixed(2)}:1) or it stops reading as secondary`
|
|
606
|
+
);
|
|
607
|
+
assert.ok(
|
|
608
|
+
contrast(p.muted, band) < header,
|
|
609
|
+
`${name}: --text2 must be an improvement on the --muted it replaced`
|
|
610
|
+
);
|
|
611
|
+
}
|
|
612
|
+
});
|
|
613
|
+
|
|
614
|
+
/**
|
|
615
|
+
* The ratios above are the tokens'. They say nothing about whether the CSS
|
|
616
|
+
* still ASKS for them - a deleted rule would leave every number here passing.
|
|
617
|
+
* These two assertions are the wiring. Neither can measure a real computed
|
|
618
|
+
* style: that needs a browser, and the measured evidence for it is in the PR.
|
|
619
|
+
*/
|
|
620
|
+
test("the stylesheets still ask for the tokens the two fixes chose", () => {
|
|
621
|
+
const css = (f: string) =>
|
|
622
|
+
readFileSync(new URL(`../assets/css/${f}`, import.meta.url), "utf8")
|
|
623
|
+
.replace(/\/\*[\s\S]*?\*\//g, "");
|
|
624
|
+
|
|
625
|
+
const primitives = css("primitives.css");
|
|
626
|
+
const optionRule = primitives.slice(primitives.indexOf("select option {"));
|
|
627
|
+
assert.ok(
|
|
628
|
+
primitives.includes("select option {"),
|
|
629
|
+
"the native <option> fill rule is gone - dark mode goes back to 1.14:1"
|
|
630
|
+
);
|
|
631
|
+
const optionBody = optionRule.slice(0, optionRule.indexOf("}"));
|
|
632
|
+
assert.ok(
|
|
633
|
+
optionBody.includes("background-color: var(--card)"),
|
|
634
|
+
"the option lost its own fill, which is the whole defect"
|
|
635
|
+
);
|
|
636
|
+
assert.ok(optionBody.includes("color: var(--text)"), "the option lost its ink");
|
|
637
|
+
|
|
638
|
+
const screens = css("screens.css");
|
|
639
|
+
const header = screens.slice(screens.indexOf(".table-card thead th {"));
|
|
640
|
+
const headerBody = header.slice(0, header.indexOf("}"));
|
|
641
|
+
assert.ok(
|
|
642
|
+
headerBody.includes("color: var(--text2) !important"),
|
|
643
|
+
"the table header is back on a token that does not clear AA in light"
|
|
644
|
+
);
|
|
645
|
+
});
|
|
646
|
+
|
|
563
647
|
/**
|
|
564
648
|
* THE BRIDGE. Every design token in `assets/css/tokens.css` is an alias onto a
|
|
565
649
|
* `--v-theme-*`, so a token whose Vuetify colour does not exist resolves to
|