Deploy AIOS web (React glide grid + FastAPI slice)
Browse files- RELEASES.json +1 -1
- VERSION +1 -1
- platform/aios_grid.py +1 -1
- web/src/customer-grid/CustomerGrid.tsx +27 -0
- web/src/customer-grid/GalleryTimelineViews.css +206 -0
- web/src/customer-grid/GalleryTimelineViews.tsx +176 -0
- web/src/customer-grid/iconShapes.ts +20 -4
- web/src/customer-grid/types.ts +1 -1
- web/src/customer-grid/viewModes.tsx +2 -2
- web/src/index.css +1 -1
- web/wiring_rows/wave43_shell_repair.py +2 -2
RELEASES.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
| 1 |
{
|
| 2 |
-
"current": "
|
| 3 |
"releases": [
|
| 4 |
{
|
| 5 |
"version": "v53",
|
|
|
|
| 1 |
{
|
| 2 |
+
"current": "80b80b9",
|
| 3 |
"releases": [
|
| 4 |
{
|
| 5 |
"version": "v53",
|
VERSION
CHANGED
|
@@ -1 +1 @@
|
|
| 1 |
-
|
|
|
|
| 1 |
+
80b80b9
|
platform/aios_grid.py
CHANGED
|
@@ -1395,7 +1395,7 @@ def _clean_rhs(raw, valid_keys):
|
|
| 1395 |
#: one lane's fence, so they ship in one change, and whether a mode may be CREATED is a separate
|
| 1396 |
#: question answered by `CREATABLE_MODES` / `HELD_MODES`.
|
| 1397 |
DISPLAY_MODES = {'grid', 'list', 'calendar', 'kanban', 'map', 'dashboard', 'chart',
|
| 1398 |
-
'timeseries', 'catalog', 'swipe', 'form', 'script'}
|
| 1399 |
|
| 1400 |
#: W37-T20 (C2) β the cap on `display.script.id`. Script-view ids are minted server-side as
|
| 1401 |
#: `'sv_' + secrets.token_urlsafe(9)` (`routes_script_views.py::create_script_view`), so ~15 chars;
|
|
|
|
| 1395 |
#: one lane's fence, so they ship in one change, and whether a mode may be CREATED is a separate
|
| 1396 |
#: question answered by `CREATABLE_MODES` / `HELD_MODES`.
|
| 1397 |
DISPLAY_MODES = {'grid', 'list', 'calendar', 'kanban', 'map', 'dashboard', 'chart',
|
| 1398 |
+
'timeseries', 'catalog', 'swipe', 'form', 'script', 'gallery', 'timeline'}
|
| 1399 |
|
| 1400 |
#: W37-T20 (C2) β the cap on `display.script.id`. Script-view ids are minted server-side as
|
| 1401 |
#: `'sv_' + secrets.token_urlsafe(9)` (`routes_script_views.py::create_script_view`), so ~15 chars;
|
web/src/customer-grid/CustomerGrid.tsx
CHANGED
|
@@ -136,6 +136,7 @@ import { MapView } from "./MapView";
|
|
| 136 |
import type { RouteSourceView } from "./MapView";
|
| 137 |
/* βββ W18-C CATALOG βββ (owner item 4, contract C6) */
|
| 138 |
import { CatalogView } from "./CatalogView";
|
|
|
|
| 139 |
import { CATALOG_CODE_FIELD } from "./catalogData";
|
| 140 |
import type { CatalogSpec } from "./types";
|
| 141 |
/* βββ end W18-C CATALOG βββ */
|
|
@@ -6889,6 +6890,8 @@ function CustomerGridSurface({
|
|
| 6889 |
const modeDataRows = useMemo(() => {
|
| 6890 |
if (
|
| 6891 |
displayMode !== "calendar" &&
|
|
|
|
|
|
|
| 6892 |
displayMode !== "kanban" &&
|
| 6893 |
displayMode !== "map" &&
|
| 6894 |
displayMode !== "chart" &&
|
|
@@ -8520,6 +8523,30 @@ function CustomerGridSurface({
|
|
| 8520 |
This table has no date field to place records by.
|
| 8521 |
</div>
|
| 8522 |
))}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8523 |
{displayMode === "kanban" &&
|
| 8524 |
(kanbanField ? (
|
| 8525 |
<KanbanView
|
|
|
|
| 136 |
import type { RouteSourceView } from "./MapView";
|
| 137 |
/* βββ W18-C CATALOG βββ (owner item 4, contract C6) */
|
| 138 |
import { CatalogView } from "./CatalogView";
|
| 139 |
+
import { GalleryView, TimelineView } from "./GalleryTimelineViews";
|
| 140 |
import { CATALOG_CODE_FIELD } from "./catalogData";
|
| 141 |
import type { CatalogSpec } from "./types";
|
| 142 |
/* βββ end W18-C CATALOG βββ */
|
|
|
|
| 6890 |
const modeDataRows = useMemo(() => {
|
| 6891 |
if (
|
| 6892 |
displayMode !== "calendar" &&
|
| 6893 |
+
displayMode !== "gallery" &&
|
| 6894 |
+
displayMode !== "timeline" &&
|
| 6895 |
displayMode !== "kanban" &&
|
| 6896 |
displayMode !== "map" &&
|
| 6897 |
displayMode !== "chart" &&
|
|
|
|
| 8523 |
This table has no date field to place records by.
|
| 8524 |
</div>
|
| 8525 |
))}
|
| 8526 |
+
{displayMode === "gallery" && (
|
| 8527 |
+
<GalleryView
|
| 8528 |
+
rows={modeDataRows}
|
| 8529 |
+
fields={fields}
|
| 8530 |
+
visibleKeys={visibleKeys}
|
| 8531 |
+
titleKey={lockedKey}
|
| 8532 |
+
onOpen={setDetailPid}
|
| 8533 |
+
/>
|
| 8534 |
+
)}
|
| 8535 |
+
{displayMode === "timeline" &&
|
| 8536 |
+
(calendarField ? (
|
| 8537 |
+
<TimelineView
|
| 8538 |
+
rows={modeDataRows}
|
| 8539 |
+
fields={fields}
|
| 8540 |
+
visibleKeys={visibleKeys}
|
| 8541 |
+
field={calendarField}
|
| 8542 |
+
titleKey={lockedKey}
|
| 8543 |
+
onOpen={setDetailPid}
|
| 8544 |
+
/>
|
| 8545 |
+
) : (
|
| 8546 |
+
<div className="cg-mode-empty">
|
| 8547 |
+
This table has no date field to place records on a timeline.
|
| 8548 |
+
</div>
|
| 8549 |
+
))}
|
| 8550 |
{displayMode === "kanban" &&
|
| 8551 |
(kanbanField ? (
|
| 8552 |
<KanbanView
|
web/src/customer-grid/GalleryTimelineViews.css
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.cg-gallery,
|
| 2 |
+
.cg-timeline {
|
| 3 |
+
padding: 16px;
|
| 4 |
+
}
|
| 5 |
+
|
| 6 |
+
.cg-gallery-bar,
|
| 7 |
+
.cg-timeline-bar {
|
| 8 |
+
display: flex;
|
| 9 |
+
align-items: center;
|
| 10 |
+
gap: 8px;
|
| 11 |
+
min-height: 32px;
|
| 12 |
+
margin-bottom: 12px;
|
| 13 |
+
color: var(--lp-muted);
|
| 14 |
+
font-size: var(--lp-fs-2xs);
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
.cg-gallery-label,
|
| 18 |
+
.cg-timeline-label {
|
| 19 |
+
display: inline-flex;
|
| 20 |
+
align-items: center;
|
| 21 |
+
gap: 6px;
|
| 22 |
+
color: var(--lp-ink);
|
| 23 |
+
font-weight: 600;
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
.cg-gallery-count,
|
| 27 |
+
.cg-timeline-count {
|
| 28 |
+
font-variant-numeric: tabular-nums;
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
.cg-timeline-missing {
|
| 32 |
+
margin-left: auto;
|
| 33 |
+
color: var(--lp-yellow-deep);
|
| 34 |
+
background: var(--lp-yellow-tint);
|
| 35 |
+
border-radius: var(--lp-r-pill);
|
| 36 |
+
padding: 3px 8px;
|
| 37 |
+
font-variant-numeric: tabular-nums;
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
.cg-gallery-grid {
|
| 41 |
+
display: grid;
|
| 42 |
+
grid-template-columns: repeat(auto-fill, minmax(224px, 1fr));
|
| 43 |
+
gap: 10px;
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
.cg-gallery-card,
|
| 47 |
+
.cg-timeline-card {
|
| 48 |
+
border: 0;
|
| 49 |
+
background: transparent;
|
| 50 |
+
color: inherit;
|
| 51 |
+
font: inherit;
|
| 52 |
+
text-align: left;
|
| 53 |
+
cursor: pointer;
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
.cg-gallery-card {
|
| 57 |
+
display: grid;
|
| 58 |
+
grid-template-columns: 36px minmax(0, 1fr);
|
| 59 |
+
gap: 10px;
|
| 60 |
+
align-items: start;
|
| 61 |
+
min-height: 108px;
|
| 62 |
+
padding: 12px;
|
| 63 |
+
border: 1px solid var(--lp-line);
|
| 64 |
+
background: var(--lp-surface);
|
| 65 |
+
color: var(--lp-ink);
|
| 66 |
+
border-radius: var(--lp-r-md);
|
| 67 |
+
transition: transform 150ms ease-out, border-color 150ms ease-out, background 150ms ease-out;
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
.cg-gallery-mark {
|
| 71 |
+
display: grid;
|
| 72 |
+
place-items: center;
|
| 73 |
+
width: 36px;
|
| 74 |
+
height: 36px;
|
| 75 |
+
border-radius: var(--lp-r-sm);
|
| 76 |
+
color: var(--lp-blue-deep);
|
| 77 |
+
background: var(--lp-blue-tint);
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
.cg-gallery-copy,
|
| 81 |
+
.cg-timeline-card {
|
| 82 |
+
display: grid;
|
| 83 |
+
gap: 7px;
|
| 84 |
+
min-width: 0;
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
.cg-gallery-copy > strong,
|
| 88 |
+
.cg-timeline-card > strong {
|
| 89 |
+
overflow: hidden;
|
| 90 |
+
text-overflow: ellipsis;
|
| 91 |
+
white-space: nowrap;
|
| 92 |
+
font-size: var(--lp-fs-sm);
|
| 93 |
+
font-weight: 600;
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
.cg-gallery-detail,
|
| 97 |
+
.cg-timeline-detail {
|
| 98 |
+
display: flex;
|
| 99 |
+
justify-content: space-between;
|
| 100 |
+
gap: 12px;
|
| 101 |
+
color: var(--lp-muted);
|
| 102 |
+
font-size: var(--lp-fs-3xs);
|
| 103 |
+
}
|
| 104 |
+
|
| 105 |
+
.cg-gallery-detail b,
|
| 106 |
+
.cg-timeline-detail b {
|
| 107 |
+
min-width: 0;
|
| 108 |
+
overflow: hidden;
|
| 109 |
+
text-overflow: ellipsis;
|
| 110 |
+
white-space: nowrap;
|
| 111 |
+
color: var(--lp-ink);
|
| 112 |
+
font-weight: 500;
|
| 113 |
+
font-variant-numeric: tabular-nums;
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
.cg-timeline-list {
|
| 117 |
+
display: grid;
|
| 118 |
+
gap: 18px;
|
| 119 |
+
}
|
| 120 |
+
|
| 121 |
+
.cg-timeline-month {
|
| 122 |
+
display: grid;
|
| 123 |
+
gap: 8px;
|
| 124 |
+
}
|
| 125 |
+
|
| 126 |
+
.cg-timeline-month > h3 {
|
| 127 |
+
margin: 0;
|
| 128 |
+
color: var(--lp-muted);
|
| 129 |
+
font-size: var(--lp-fs-2xs);
|
| 130 |
+
font-weight: 600;
|
| 131 |
+
}
|
| 132 |
+
|
| 133 |
+
.cg-timeline-event {
|
| 134 |
+
display: grid;
|
| 135 |
+
grid-template-columns: 72px minmax(0, 1fr);
|
| 136 |
+
gap: 12px;
|
| 137 |
+
align-items: stretch;
|
| 138 |
+
}
|
| 139 |
+
|
| 140 |
+
.cg-timeline-date {
|
| 141 |
+
position: relative;
|
| 142 |
+
padding-top: 12px;
|
| 143 |
+
color: var(--lp-muted);
|
| 144 |
+
font-size: var(--lp-fs-3xs);
|
| 145 |
+
text-align: right;
|
| 146 |
+
}
|
| 147 |
+
|
| 148 |
+
.cg-timeline-date::after {
|
| 149 |
+
position: absolute;
|
| 150 |
+
top: 14px;
|
| 151 |
+
right: -18px;
|
| 152 |
+
width: 8px;
|
| 153 |
+
height: 8px;
|
| 154 |
+
border-radius: var(--lp-r-pill);
|
| 155 |
+
background: var(--lp-yellow-deep);
|
| 156 |
+
content: "";
|
| 157 |
+
}
|
| 158 |
+
|
| 159 |
+
.cg-timeline-card {
|
| 160 |
+
position: relative;
|
| 161 |
+
padding: 11px 12px;
|
| 162 |
+
border: 1px solid var(--lp-line);
|
| 163 |
+
background: var(--lp-surface);
|
| 164 |
+
color: var(--lp-ink);
|
| 165 |
+
border-radius: var(--lp-r-md);
|
| 166 |
+
transition: transform 150ms ease-out, border-color 150ms ease-out, background 150ms ease-out;
|
| 167 |
+
}
|
| 168 |
+
|
| 169 |
+
.cg-timeline-card::before {
|
| 170 |
+
position: absolute;
|
| 171 |
+
top: -9px;
|
| 172 |
+
bottom: -9px;
|
| 173 |
+
left: -17px;
|
| 174 |
+
width: 1px;
|
| 175 |
+
background: var(--lp-line);
|
| 176 |
+
content: "";
|
| 177 |
+
}
|
| 178 |
+
|
| 179 |
+
.cg-gallery-card:focus-visible,
|
| 180 |
+
.cg-timeline-card:focus-visible {
|
| 181 |
+
outline: 2px solid var(--lp-blue-deep);
|
| 182 |
+
outline-offset: 2px;
|
| 183 |
+
}
|
| 184 |
+
|
| 185 |
+
@media (hover: hover) and (pointer: fine) {
|
| 186 |
+
.cg-gallery-card:hover,
|
| 187 |
+
.cg-timeline-card:hover {
|
| 188 |
+
border-color: var(--lp-blue-deep);
|
| 189 |
+
background: var(--lp-surface-2);
|
| 190 |
+
transform: translateY(-1px);
|
| 191 |
+
}
|
| 192 |
+
}
|
| 193 |
+
|
| 194 |
+
@media (prefers-reduced-motion: reduce) {
|
| 195 |
+
.cg-gallery-card,
|
| 196 |
+
.cg-timeline-card {
|
| 197 |
+
transition: none;
|
| 198 |
+
}
|
| 199 |
+
}
|
| 200 |
+
|
| 201 |
+
@media (max-width: 620px) {
|
| 202 |
+
.cg-timeline-event {
|
| 203 |
+
grid-template-columns: 58px minmax(0, 1fr);
|
| 204 |
+
gap: 10px;
|
| 205 |
+
}
|
| 206 |
+
}
|
web/src/customer-grid/GalleryTimelineViews.tsx
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { memo, useMemo } from "react";
|
| 2 |
+
|
| 3 |
+
import { NAV_MINIMIZE_EVENT, signal } from "../apiContract";
|
| 4 |
+
import { formatDisplay } from "./display";
|
| 5 |
+
import { ModeIcon } from "./icons";
|
| 6 |
+
import type { Field, Row } from "./types";
|
| 7 |
+
import "./GalleryTimelineViews.css";
|
| 8 |
+
|
| 9 |
+
function visibleDetailFields(fields: Field[], visibleKeys: string[], excluded: string[]) {
|
| 10 |
+
const byKey = new Map(fields.map((field) => [field.key, field]));
|
| 11 |
+
return visibleKeys
|
| 12 |
+
.filter((key) => !excluded.includes(key))
|
| 13 |
+
.map((key) => byKey.get(key))
|
| 14 |
+
.filter((field): field is Field => !!field)
|
| 15 |
+
.slice(0, 3);
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
function titleFor(row: Row, titleKey: string) {
|
| 19 |
+
const value = String(row[titleKey] ?? "").trim();
|
| 20 |
+
return value || "Untitled record";
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
function openRecord(pid: number, detail: number, onOpen: (pid: number) => void) {
|
| 24 |
+
if (detail <= 1) signal(NAV_MINIMIZE_EVENT);
|
| 25 |
+
onOpen(pid);
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
/** A quiet card arrangement over the exact filtered rows the Grid would paint. */
|
| 29 |
+
export const GalleryView = memo(function GalleryView({
|
| 30 |
+
rows,
|
| 31 |
+
fields,
|
| 32 |
+
visibleKeys,
|
| 33 |
+
titleKey,
|
| 34 |
+
onOpen,
|
| 35 |
+
}: {
|
| 36 |
+
rows: Row[];
|
| 37 |
+
fields: Field[];
|
| 38 |
+
visibleKeys: string[];
|
| 39 |
+
titleKey: string;
|
| 40 |
+
onOpen: (pid: number) => void;
|
| 41 |
+
}) {
|
| 42 |
+
const details = useMemo(
|
| 43 |
+
() => visibleDetailFields(fields, visibleKeys, [titleKey]),
|
| 44 |
+
[fields, visibleKeys, titleKey]
|
| 45 |
+
);
|
| 46 |
+
|
| 47 |
+
if (!rows.length) return <div className="cg-mode-empty">No records match this view.</div>;
|
| 48 |
+
|
| 49 |
+
return (
|
| 50 |
+
<section className="cg-gallery" aria-label="Gallery">
|
| 51 |
+
<div className="cg-gallery-bar">
|
| 52 |
+
<span className="cg-gallery-label"><ModeIcon mode="gallery" /> Gallery</span>
|
| 53 |
+
<span className="cg-gallery-count">{rows.length.toLocaleString()} records</span>
|
| 54 |
+
</div>
|
| 55 |
+
<div className="cg-gallery-grid">
|
| 56 |
+
{rows.map((row) => {
|
| 57 |
+
const title = titleFor(row, titleKey);
|
| 58 |
+
return (
|
| 59 |
+
<button
|
| 60 |
+
type="button"
|
| 61 |
+
className="cg-gallery-card"
|
| 62 |
+
key={row.pid}
|
| 63 |
+
onClick={(event) => openRecord(row.pid, event.detail, onOpen)}
|
| 64 |
+
aria-label={`Open ${title}`}
|
| 65 |
+
>
|
| 66 |
+
<span className="cg-gallery-mark" aria-hidden><ModeIcon mode="gallery" /></span>
|
| 67 |
+
<span className="cg-gallery-copy">
|
| 68 |
+
<strong>{title}</strong>
|
| 69 |
+
{details.map((field) => (
|
| 70 |
+
<span className="cg-gallery-detail" key={field.key}>
|
| 71 |
+
<span>{field.label}</span>
|
| 72 |
+
<b>{formatDisplay(field, row[field.key])}</b>
|
| 73 |
+
</span>
|
| 74 |
+
))}
|
| 75 |
+
</span>
|
| 76 |
+
</button>
|
| 77 |
+
);
|
| 78 |
+
})}
|
| 79 |
+
</div>
|
| 80 |
+
</section>
|
| 81 |
+
);
|
| 82 |
+
});
|
| 83 |
+
|
| 84 |
+
type TimelineEvent = { row: Row; day: string; title: string };
|
| 85 |
+
|
| 86 |
+
function monthLabel(day: string) {
|
| 87 |
+
return new Intl.DateTimeFormat(undefined, { month: "long", year: "numeric", timeZone: "UTC" })
|
| 88 |
+
.format(new Date(`${day.slice(0, 7)}-01T00:00:00Z`));
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
function dayLabel(day: string) {
|
| 92 |
+
return new Intl.DateTimeFormat(undefined, { weekday: "short", day: "numeric", timeZone: "UTC" })
|
| 93 |
+
.format(new Date(`${day}T00:00:00Z`));
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
/** A chronological projection. It never writes dates: editing remains in the normal record drawer. */
|
| 97 |
+
export const TimelineView = memo(function TimelineView({
|
| 98 |
+
rows,
|
| 99 |
+
fields,
|
| 100 |
+
visibleKeys,
|
| 101 |
+
field,
|
| 102 |
+
titleKey,
|
| 103 |
+
onOpen,
|
| 104 |
+
}: {
|
| 105 |
+
rows: Row[];
|
| 106 |
+
fields: Field[];
|
| 107 |
+
visibleKeys: string[];
|
| 108 |
+
field: Field;
|
| 109 |
+
titleKey: string;
|
| 110 |
+
onOpen: (pid: number) => void;
|
| 111 |
+
}) {
|
| 112 |
+
const events = useMemo<TimelineEvent[]>(
|
| 113 |
+
() => rows
|
| 114 |
+
.map((row) => ({ row, day: String(row[field.key] ?? "").slice(0, 10), title: titleFor(row, titleKey) }))
|
| 115 |
+
.filter((event) => /^\d{4}-\d{2}-\d{2}$/.test(event.day))
|
| 116 |
+
.sort((a, b) => a.day.localeCompare(b.day) || a.title.localeCompare(b.title)),
|
| 117 |
+
[rows, field.key, titleKey]
|
| 118 |
+
);
|
| 119 |
+
const details = useMemo(
|
| 120 |
+
() => visibleDetailFields(fields, visibleKeys, [titleKey, field.key]),
|
| 121 |
+
[fields, visibleKeys, titleKey, field.key]
|
| 122 |
+
);
|
| 123 |
+
const missing = rows.length - events.length;
|
| 124 |
+
const months = useMemo(() => {
|
| 125 |
+
const grouped = new Map<string, TimelineEvent[]>();
|
| 126 |
+
for (const event of events) {
|
| 127 |
+
const key = event.day.slice(0, 7);
|
| 128 |
+
grouped.set(key, [...(grouped.get(key) ?? []), event]);
|
| 129 |
+
}
|
| 130 |
+
return [...grouped.entries()];
|
| 131 |
+
}, [events]);
|
| 132 |
+
|
| 133 |
+
if (!events.length) {
|
| 134 |
+
return (
|
| 135 |
+
<div className="cg-mode-empty">
|
| 136 |
+
No records have a valid {field.label} date for this timeline.
|
| 137 |
+
</div>
|
| 138 |
+
);
|
| 139 |
+
}
|
| 140 |
+
|
| 141 |
+
return (
|
| 142 |
+
<section className="cg-timeline" aria-label="Timeline">
|
| 143 |
+
<div className="cg-timeline-bar">
|
| 144 |
+
<span className="cg-timeline-label"><ModeIcon mode="timeline" /> Timeline</span>
|
| 145 |
+
<span className="cg-timeline-count">{events.length.toLocaleString()} dated records Β· by {field.label}</span>
|
| 146 |
+
{missing > 0 && <span className="cg-timeline-missing">{missing.toLocaleString()} without a date</span>}
|
| 147 |
+
</div>
|
| 148 |
+
<div className="cg-timeline-list">
|
| 149 |
+
{months.map(([month, monthEvents]) => (
|
| 150 |
+
<section className="cg-timeline-month" key={month} aria-label={monthLabel(`${month}-01`)}>
|
| 151 |
+
<h3>{monthLabel(`${month}-01`)}</h3>
|
| 152 |
+
{monthEvents.map((event) => (
|
| 153 |
+
<div className="cg-timeline-event" key={event.row.pid}>
|
| 154 |
+
<span className="cg-timeline-date">{dayLabel(event.day)}</span>
|
| 155 |
+
<button
|
| 156 |
+
type="button"
|
| 157 |
+
className="cg-timeline-card"
|
| 158 |
+
onClick={(click) => openRecord(event.row.pid, click.detail, onOpen)}
|
| 159 |
+
aria-label={`Open ${event.title}`}
|
| 160 |
+
>
|
| 161 |
+
<strong>{event.title}</strong>
|
| 162 |
+
{details.map((detail) => (
|
| 163 |
+
<span className="cg-timeline-detail" key={detail.key}>
|
| 164 |
+
<span>{detail.label}</span>
|
| 165 |
+
<b>{formatDisplay(detail, event.row[detail.key])}</b>
|
| 166 |
+
</span>
|
| 167 |
+
))}
|
| 168 |
+
</button>
|
| 169 |
+
</div>
|
| 170 |
+
))}
|
| 171 |
+
</section>
|
| 172 |
+
))}
|
| 173 |
+
</div>
|
| 174 |
+
</section>
|
| 175 |
+
);
|
| 176 |
+
});
|
web/src/customer-grid/iconShapes.ts
CHANGED
|
@@ -196,6 +196,15 @@ export const MODE_SHAPES: Record<DisplayMode, IconShape[]> = {
|
|
| 196 |
{ d: "M5.4 10.6V7.2M8 10.6V5.4M10.6 10.6V8.6" },
|
| 197 |
],
|
| 198 |
calendar: CALENDAR,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 199 |
kanban: [{ d: "M2.8 3.4h3.1v9.2H2.8zM6.5 3.4h3.1v6.2H6.5zM10.2 3.4h3.1v7.6h-3.1z" }],
|
| 200 |
map: [
|
| 201 |
{ d: "M8 2.6a3.6 3.6 0 0 1 3.6 3.6c0 2.7-3.6 7.2-3.6 7.2S4.4 8.9 4.4 6.2A3.6 3.6 0 0 1 8 2.6z" },
|
|
@@ -416,6 +425,8 @@ export const MODE_LABELS: Record<DisplayMode, string> = {
|
|
| 416 |
dashboard: "Chart",
|
| 417 |
list: "List",
|
| 418 |
calendar: "Calendar",
|
|
|
|
|
|
|
| 419 |
kanban: "Kanban",
|
| 420 |
map: "Map",
|
| 421 |
timeseries: "Time series",
|
|
@@ -477,8 +488,8 @@ export const MODE_LABELS: Record<DisplayMode, string> = {
|
|
| 477 |
// Wave-18 C6-CATALOG β `catalog` was held out of this list until `aios_grid.DISPLAY_MODES`
|
| 478 |
// accepted the name, the same hold `timeseries` and `chart` served before it. SESSION A posted
|
| 479 |
// "C6 HOST MIRROR APPLIED β you may flip CREATABLE_MODES now" (2026-08-03), so it is offerable.
|
| 480 |
-
//
|
| 481 |
-
//
|
| 482 |
// β Wave-27 C3 (owner item 8) β `swipe` is HELD OUT of this list, and the reason corrects a
|
| 483 |
// mistake this file made an hour earlier.
|
| 484 |
//
|
|
@@ -508,8 +519,7 @@ export const MODE_LABELS: Record<DisplayMode, string> = {
|
|
| 508 |
// for its CONSUMER β who imports/mounts/registers it β excluding the file itself, `_test/`, css
|
| 509 |
// and comments. `SwipeView` had four hits and three were prose.
|
| 510 |
// Mounted 2026-08-09 (`CustomerGrid.tsx`, `displayMode === "swipe"`), so `swipe` is offerable.
|
| 511 |
-
//
|
| 512 |
-
// mode joins the end of it rather than being sorted into it.
|
| 513 |
export const CREATABLE_MODES: DisplayMode[] = [
|
| 514 |
"grid",
|
| 515 |
"chart",
|
|
@@ -547,6 +557,8 @@ export const CREATABLE_MODES: DisplayMode[] = [
|
|
| 547 |
// it on a pool scope would mint a view the rail can never show
|
| 548 |
// ([[permitted-is-not-answerable]]). It lands LAST by `catalog`'s rule.
|
| 549 |
"script",
|
|
|
|
|
|
|
| 550 |
];
|
| 551 |
|
| 552 |
/**
|
|
@@ -583,6 +595,8 @@ export const MODE_GROUP: Record<DisplayMode, ModeGroup> = {
|
|
| 583 |
list: "View",
|
| 584 |
kanban: "View",
|
| 585 |
calendar: "View",
|
|
|
|
|
|
|
| 586 |
chart: "View",
|
| 587 |
dashboard: "View",
|
| 588 |
// β WAVE-33 item 9 (owner, verbatim): "Let's move Swipe and Time-series under Interface instead
|
|
@@ -619,6 +633,8 @@ export const MODE_TONE: Record<DisplayMode, FolderTone> = {
|
|
| 619 |
chart: "green",
|
| 620 |
dashboard: "green",
|
| 621 |
calendar: "yellow",
|
|
|
|
|
|
|
| 622 |
kanban: "yellow",
|
| 623 |
map: "red",
|
| 624 |
// Green with `chart`: it is the other analytical mode, and the two belong to one family.
|
|
|
|
| 196 |
{ d: "M5.4 10.6V7.2M8 10.6V5.4M10.6 10.6V8.6" },
|
| 197 |
],
|
| 198 |
calendar: CALENDAR,
|
| 199 |
+
gallery: [
|
| 200 |
+
{ d: "M2.8 3.2h4.1v4.1H2.8zM9.1 3.2h4.1v4.1H9.1zM2.8 9.1h4.1v3.7H2.8zM9.1 9.1h4.1v3.7H9.1z" },
|
| 201 |
+
],
|
| 202 |
+
timeline: [
|
| 203 |
+
{ d: "M4.1 3v10M4.1 5.2h2.1M4.1 8h4.2M4.1 10.8h2.1" },
|
| 204 |
+
{ d: circle(4.1, 5.2, 0.8) },
|
| 205 |
+
{ d: circle(4.1, 8, 0.8) },
|
| 206 |
+
{ d: circle(4.1, 10.8, 0.8) },
|
| 207 |
+
],
|
| 208 |
kanban: [{ d: "M2.8 3.4h3.1v9.2H2.8zM6.5 3.4h3.1v6.2H6.5zM10.2 3.4h3.1v7.6h-3.1z" }],
|
| 209 |
map: [
|
| 210 |
{ d: "M8 2.6a3.6 3.6 0 0 1 3.6 3.6c0 2.7-3.6 7.2-3.6 7.2S4.4 8.9 4.4 6.2A3.6 3.6 0 0 1 8 2.6z" },
|
|
|
|
| 425 |
dashboard: "Chart",
|
| 426 |
list: "List",
|
| 427 |
calendar: "Calendar",
|
| 428 |
+
gallery: "Gallery",
|
| 429 |
+
timeline: "Timeline",
|
| 430 |
kanban: "Kanban",
|
| 431 |
map: "Map",
|
| 432 |
timeseries: "Time series",
|
|
|
|
| 488 |
// Wave-18 C6-CATALOG β `catalog` was held out of this list until `aios_grid.DISPLAY_MODES`
|
| 489 |
// accepted the name, the same hold `timeseries` and `chart` served before it. SESSION A posted
|
| 490 |
// "C6 HOST MIRROR APPLIED β you may flip CREATABLE_MODES now" (2026-08-03), so it is offerable.
|
| 491 |
+
// Each new mode joins the end by contract: the owner's order above is a product decision rather
|
| 492 |
+
// than a candidate for alphabetical sorting.
|
| 493 |
// β Wave-27 C3 (owner item 8) β `swipe` is HELD OUT of this list, and the reason corrects a
|
| 494 |
// mistake this file made an hour earlier.
|
| 495 |
//
|
|
|
|
| 519 |
// for its CONSUMER β who imports/mounts/registers it β excluding the file itself, `_test/`, css
|
| 520 |
// and comments. `SwipeView` had four hits and three were prose.
|
| 521 |
// Mounted 2026-08-09 (`CustomerGrid.tsx`, `displayMode === "swipe"`), so `swipe` is offerable.
|
| 522 |
+
// Gallery and Timeline later join after Script by that same append-only ordering rule.
|
|
|
|
| 523 |
export const CREATABLE_MODES: DisplayMode[] = [
|
| 524 |
"grid",
|
| 525 |
"chart",
|
|
|
|
| 557 |
// it on a pool scope would mint a view the rail can never show
|
| 558 |
// ([[permitted-is-not-answerable]]). It lands LAST by `catalog`'s rule.
|
| 559 |
"script",
|
| 560 |
+
"gallery",
|
| 561 |
+
"timeline",
|
| 562 |
];
|
| 563 |
|
| 564 |
/**
|
|
|
|
| 595 |
list: "View",
|
| 596 |
kanban: "View",
|
| 597 |
calendar: "View",
|
| 598 |
+
gallery: "View",
|
| 599 |
+
timeline: "View",
|
| 600 |
chart: "View",
|
| 601 |
dashboard: "View",
|
| 602 |
// β WAVE-33 item 9 (owner, verbatim): "Let's move Swipe and Time-series under Interface instead
|
|
|
|
| 633 |
chart: "green",
|
| 634 |
dashboard: "green",
|
| 635 |
calendar: "yellow",
|
| 636 |
+
gallery: "blue",
|
| 637 |
+
timeline: "yellow",
|
| 638 |
kanban: "yellow",
|
| 639 |
map: "red",
|
| 640 |
// Green with `chart`: it is the other analytical mode, and the two belong to one family.
|
web/src/customer-grid/types.ts
CHANGED
|
@@ -573,7 +573,7 @@ export type FieldScope = "cohort" | "global";
|
|
| 573 |
// stored `script` view draws rather than blanking if the host mirror ever lands.
|
| 574 |
export const DISPLAY_MODES = [
|
| 575 |
"grid", "chart", "dashboard", "list", "calendar", "kanban", "map", "timeseries", "catalog",
|
| 576 |
-
"form", "swipe", "script",
|
| 577 |
] as const;
|
| 578 |
export type DisplayMode = (typeof DISPLAY_MODES)[number];
|
| 579 |
|
|
|
|
| 573 |
// stored `script` view draws rather than blanking if the host mirror ever lands.
|
| 574 |
export const DISPLAY_MODES = [
|
| 575 |
"grid", "chart", "dashboard", "list", "calendar", "kanban", "map", "timeseries", "catalog",
|
| 576 |
+
"form", "swipe", "script", "gallery", "timeline",
|
| 577 |
] as const;
|
| 578 |
export type DisplayMode = (typeof DISPLAY_MODES)[number];
|
| 579 |
|
web/src/customer-grid/viewModes.tsx
CHANGED
|
@@ -251,10 +251,10 @@ export function ModeSwitch({
|
|
| 251 |
date field and a board without a stack field are not modes, so the picker is never
|
| 252 |
in its placeholder state here unless the view names a field the table has lost β
|
| 253 |
which it now SHOWS rather than silently replacing with the first choice. */}
|
| 254 |
-
{mode === "calendar" && dateChoices.length > 0 && (
|
| 255 |
<FieldSelectButton
|
| 256 |
className="cg-mode-field"
|
| 257 |
-
ariaLabel="Calendar date field"
|
| 258 |
value={dateField?.key}
|
| 259 |
onChange={(key) => onPickField("dateField", key)}
|
| 260 |
fields={dateChoices.map((f) => ({ key: f.key, label: f.label, type: f.type }))}
|
|
|
|
| 251 |
date field and a board without a stack field are not modes, so the picker is never
|
| 252 |
in its placeholder state here unless the view names a field the table has lost β
|
| 253 |
which it now SHOWS rather than silently replacing with the first choice. */}
|
| 254 |
+
{(mode === "calendar" || mode === "timeline") && dateChoices.length > 0 && (
|
| 255 |
<FieldSelectButton
|
| 256 |
className="cg-mode-field"
|
| 257 |
+
ariaLabel={mode === "calendar" ? "Calendar date field" : "Timeline date field"}
|
| 258 |
value={dateField?.key}
|
| 259 |
onChange={(key) => onPickField("dateField", key)}
|
| 260 |
fields={dateChoices.map((f) => ({ key: f.key, label: f.label, type: f.type }))}
|
web/src/index.css
CHANGED
|
@@ -13471,7 +13471,7 @@ input.conn-odoo-v:disabled { background: var(--lp-surface-2); color: var(--lp-mu
|
|
| 13471 |
.cg-date-grid { margin-top: 3px; }
|
| 13472 |
.cg-date-day { min-height: 27px; border: 0; border-radius: var(--lp-r-sm); background: transparent; color: var(--lp-ink); cursor: pointer; font: inherit; font-size: var(--lp-fs-2xs); }
|
| 13473 |
.cg-date-day:hover, .cg-date-day:focus-visible { background: var(--lp-surface-2); outline: none; }
|
| 13474 |
-
.cg-date-day.is-selected { background: var(--lp-blue-solid); color: var(--lp-
|
| 13475 |
.cg-date-recurrence, .cg-date-preview { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; margin-top: 10px; padding-top: 8px; border-top: 1px solid var(--lp-line); font-size: var(--lp-fs-2xs); }
|
| 13476 |
.cg-date-recurrence .cg-pop-title, .cg-date-preview .cg-pop-title { width: 100%; }
|
| 13477 |
.cg-date-interval { width: 42px; padding: 3px; }
|
|
|
|
| 13471 |
.cg-date-grid { margin-top: 3px; }
|
| 13472 |
.cg-date-day { min-height: 27px; border: 0; border-radius: var(--lp-r-sm); background: transparent; color: var(--lp-ink); cursor: pointer; font: inherit; font-size: var(--lp-fs-2xs); }
|
| 13473 |
.cg-date-day:hover, .cg-date-day:focus-visible { background: var(--lp-surface-2); outline: none; }
|
| 13474 |
+
.cg-date-day.is-selected { background: var(--lp-blue-solid); color: var(--lp-surface); }
|
| 13475 |
.cg-date-recurrence, .cg-date-preview { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; margin-top: 10px; padding-top: 8px; border-top: 1px solid var(--lp-line); font-size: var(--lp-fs-2xs); }
|
| 13476 |
.cg-date-recurrence .cg-pop-title, .cg-date-preview .cg-pop-title { width: 100%; }
|
| 13477 |
.cg-date-interval { width: 42px; padding: 3px; }
|
web/wiring_rows/wave43_shell_repair.py
CHANGED
|
@@ -17,9 +17,9 @@ ROWS: list[tuple[str, str, str, str, str]] = [
|
|
| 17 |
r'onChange=\{\(value\) => patchAndRecord\(datePicker\.pid, \{ \[dateField\.key\]: value \}, "a date"\)\}'),
|
| 18 |
("W43C-T02 recurrence is a required DatePicker contract and persists the field definition",
|
| 19 |
"customer-grid/DatePicker.tsx",
|
| 20 |
-
r'onRecurrence: \(recurrence:
|
| 21 |
"customer-grid/CustomerGrid.tsx",
|
| 22 |
-
r'onRecurrence=\{\(recurrence
|
| 23 |
]
|
| 24 |
|
| 25 |
DELETIONS: list[tuple[str, str, str, str]] = []
|
|
|
|
| 17 |
r'onChange=\{\(value\) => patchAndRecord\(datePicker\.pid, \{ \[dateField\.key\]: value \}, "a date"\)\}'),
|
| 18 |
("W43C-T02 recurrence is a required DatePicker contract and persists the field definition",
|
| 19 |
"customer-grid/DatePicker.tsx",
|
| 20 |
+
r'onRecurrence: \(recurrence: DateRecurrence \| null\) => void;',
|
| 21 |
"customer-grid/CustomerGrid.tsx",
|
| 22 |
+
r'onRecurrence=\{\(recurrence\) => saveField\(\{ \.\.\.dateField, recurrence: recurrence \?\? null \}\)\}'),
|
| 23 |
]
|
| 24 |
|
| 25 |
DELETIONS: list[tuple[str, str, str, str]] = []
|