feat(client): split the insights panel into 2:3 columns
This commit is contained in:
1 parent
8de11e2c32
commit
2500ddb03c
2 files changed
+120
-108
No files matched your search
@@ -6,12 +6,12 @@
|
||||
* projection values), so the panel draws every session's insight without
|
||||
* opening one log.
|
||||
*
|
||||
* Top to bottom: the range picker (7d / 30d / all) scopes the insight band —
|
||||
* the KPI 2×2 block and the activity heatmap share one folding row — above
|
||||
* the session grid; the activity heatmap keeps its own fixed 8-week window
|
||||
* and PINs the grid to a picked day (the panel's drill-down gesture). A
|
||||
* session card click jumps to that session through the harness's own
|
||||
* `sessions.open` and closes the panel.
|
||||
* The body is a 2:3 column pair: the insight column (the KPI 2×2 block over
|
||||
* the activity heatmap) beside the session column (search, group chips, and
|
||||
* the card grid); the heatmap keeps its own fixed 8-week window and PINs the
|
||||
* list to a picked day (the panel's drill-down gesture). A session card
|
||||
* click jumps to that session through the harness's own `sessions.open` and
|
||||
* closes the panel.
|
||||
*/
|
||||
|
||||
import { useEffect, useMemo, useState, useSyncExternalStore, type ReactElement } from 'react'
|
||||
@@ -134,8 +134,8 @@ export function makeOverviewPanel(ctx: ClientCtx, kit: ViewKit): (props: Overvie
|
||||
{rows === null ? (
|
||||
<div className="lc-empty">{t('ov.unavailable')}</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="lc-ov-mid">
|
||||
<div className="lc-ov-body">
|
||||
<div className="lc-ov-left">
|
||||
<div className="lc-ov-kpis">
|
||||
<div className="lc-stat lc-ov-kpi">
|
||||
<span className="lc-stat-label">{t('ov.kpi.sessions')}</span>
|
||||
@@ -167,90 +167,92 @@ export function makeOverviewPanel(ctx: ClientCtx, kit: ViewKit): (props: Overvie
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="lc-ov-list-head">
|
||||
<span className="lc-ov-list-title">{t('ov.list.title')}</span>
|
||||
<span className="lc-ov-list-count">{visible.length}</span>
|
||||
{day !== null && (
|
||||
<button type="button" className="lc-ov-day-chip" title={t('ov.list.dayClear')} onClick={() => { setDay(null) }}>
|
||||
{t('ov.list.dayFilter', { day })} ×
|
||||
</button>
|
||||
)}
|
||||
<input
|
||||
className="lc-ov-search"
|
||||
type="search"
|
||||
value={query}
|
||||
placeholder={t('ov.list.search')}
|
||||
aria-label={t('ov.list.search')}
|
||||
onChange={(ev) => { setQuery(ev.target.value) }}
|
||||
/>
|
||||
<div className="lc-gran" role="group" aria-label={t('ov.list.sortLabel')}>
|
||||
{SORTS.map(s => (
|
||||
<button
|
||||
key={s}
|
||||
type="button"
|
||||
className={'lc-gran-btn' + (sort === s ? ' lc-gran-on' : '')}
|
||||
onClick={() => { setSort(s) }}
|
||||
>{t('ov.list.sort.' + s)}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{chips.length > 0 && (
|
||||
<div className="lc-ov-groups" role="group" aria-label={t('ov.group.label')}>
|
||||
<button
|
||||
type="button"
|
||||
className={'lc-ov-chip' + (group === null ? ' lc-ov-chip-on' : '')}
|
||||
onClick={() => { setGroup(null) }}
|
||||
>{t('ov.range.all')}<span className="lc-ov-chip-n">{scoped.length}</span></button>
|
||||
{chips.map(c => (
|
||||
<button
|
||||
key={c.key}
|
||||
type="button"
|
||||
className={'lc-ov-chip' + (group === c.key ? ' lc-ov-chip-on' : '')}
|
||||
onClick={() => { setGroup(group === c.key ? null : c.key) }}
|
||||
>{c.key === UNGROUPED_KEY ? t('ov.group.ungrouped') : c.key}<span className="lc-ov-chip-n">{c.count}</span></button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{visible.length === 0 ? (
|
||||
<div className="lc-empty">{t(allRows.length === 0 ? 'ov.list.empty' : 'ov.list.noMatch')}</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="lc-ov-grid">
|
||||
{paged.items.map(row => (
|
||||
<OverviewCard
|
||||
key={row.id}
|
||||
row={row}
|
||||
{...(groups?.[row.id] !== undefined ? { group: groups[row.id] } : {})}
|
||||
costLabel={cardCostOf(row, prices, currency)}
|
||||
now={now}
|
||||
onOpen={openOne}
|
||||
/>
|
||||
<div className="lc-ov-right">
|
||||
<div className="lc-ov-list-head">
|
||||
<span className="lc-ov-list-title">{t('ov.list.title')}</span>
|
||||
<span className="lc-ov-list-count">{visible.length}</span>
|
||||
{day !== null && (
|
||||
<button type="button" className="lc-ov-day-chip" title={t('ov.list.dayClear')} onClick={() => { setDay(null) }}>
|
||||
{t('ov.list.dayFilter', { day })} ×
|
||||
</button>
|
||||
)}
|
||||
<input
|
||||
className="lc-ov-search"
|
||||
type="search"
|
||||
value={query}
|
||||
placeholder={t('ov.list.search')}
|
||||
aria-label={t('ov.list.search')}
|
||||
onChange={(ev) => { setQuery(ev.target.value) }}
|
||||
/>
|
||||
<div className="lc-gran" role="group" aria-label={t('ov.list.sortLabel')}>
|
||||
{SORTS.map(s => (
|
||||
<button
|
||||
key={s}
|
||||
type="button"
|
||||
className={'lc-gran-btn' + (sort === s ? ' lc-gran-on' : '')}
|
||||
onClick={() => { setSort(s) }}
|
||||
>{t('ov.list.sort.' + s)}</button>
|
||||
))}
|
||||
</div>
|
||||
{paged.count > 1 && (
|
||||
<div className="lc-ov-pager" role="navigation" aria-label={t('ov.list.pager')}>
|
||||
</div>
|
||||
|
||||
{chips.length > 0 && (
|
||||
<div className="lc-ov-groups" role="group" aria-label={t('ov.group.label')}>
|
||||
<button
|
||||
type="button"
|
||||
className={'lc-ov-chip' + (group === null ? ' lc-ov-chip-on' : '')}
|
||||
onClick={() => { setGroup(null) }}
|
||||
>{t('ov.range.all')}<span className="lc-ov-chip-n">{scoped.length}</span></button>
|
||||
{chips.map(c => (
|
||||
<button
|
||||
key={c.key}
|
||||
type="button"
|
||||
className="lc-ov-pager-btn"
|
||||
disabled={paged.index === 0}
|
||||
aria-label={t('ov.list.prev')}
|
||||
onClick={() => { setPage(paged.index - 1) }}
|
||||
>‹</button>
|
||||
<span className="lc-ov-pager-n">{t('ov.list.page', { n: paged.index + 1, total: paged.count })}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="lc-ov-pager-btn"
|
||||
disabled={paged.index === paged.count - 1}
|
||||
aria-label={t('ov.list.next')}
|
||||
onClick={() => { setPage(paged.index + 1) }}
|
||||
>›</button>
|
||||
className={'lc-ov-chip' + (group === c.key ? ' lc-ov-chip-on' : '')}
|
||||
onClick={() => { setGroup(group === c.key ? null : c.key) }}
|
||||
>{c.key === UNGROUPED_KEY ? t('ov.group.ungrouped') : c.key}<span className="lc-ov-chip-n">{c.count}</span></button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{visible.length === 0 ? (
|
||||
<div className="lc-empty">{t(allRows.length === 0 ? 'ov.list.empty' : 'ov.list.noMatch')}</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="lc-ov-grid">
|
||||
{paged.items.map(row => (
|
||||
<OverviewCard
|
||||
key={row.id}
|
||||
row={row}
|
||||
{...(groups?.[row.id] !== undefined ? { group: groups[row.id] } : {})}
|
||||
costLabel={cardCostOf(row, prices, currency)}
|
||||
now={now}
|
||||
onOpen={openOne}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
{paged.count > 1 && (
|
||||
<div className="lc-ov-pager" role="navigation" aria-label={t('ov.list.pager')}>
|
||||
<button
|
||||
type="button"
|
||||
className="lc-ov-pager-btn"
|
||||
disabled={paged.index === 0}
|
||||
aria-label={t('ov.list.prev')}
|
||||
onClick={() => { setPage(paged.index - 1) }}
|
||||
>‹</button>
|
||||
<span className="lc-ov-pager-n">{t('ov.list.page', { n: paged.index + 1, total: paged.count })}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="lc-ov-pager-btn"
|
||||
disabled={paged.index === paged.count - 1}
|
||||
aria-label={t('ov.list.next')}
|
||||
onClick={() => { setPage(paged.index + 1) }}
|
||||
>›</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
/*
|
||||
* The Context Dashboard: the sidebar-foot entry button (stacked above
|
||||
* The Context Insights panel: the sidebar-foot entry button (stacked above
|
||||
* Settings by the harness's own foot layout) and the frame-wide overlay
|
||||
* panel it opens — KPI band, activity heatmap, and the session card grid.
|
||||
* The overlay card is the `lc-ov` query container, so every fold (chart
|
||||
* stack, grid columns) keys off the card's own width. Inline-size
|
||||
* containment is safe here: the card carries no fullscreen overlay of its
|
||||
* own (heat tooltips are native), so the fixed-containing-block trap cannot
|
||||
* catch anything.
|
||||
* panel it opens — a 2:3 body of insight column (KPI 2×2 block over the
|
||||
* activity heatmap) beside the session column (search, chips, card grid).
|
||||
* The overlay card is the `lc-ov` query container, so the column fold keys
|
||||
* off the card's own width. Inline-size containment is safe here: the card
|
||||
* carries no fullscreen overlay of its own, and the heat cells' Tooltip
|
||||
* bubbles position correctly against the viewport despite the card's layout
|
||||
* containment (checked in the browser).
|
||||
*/
|
||||
|
||||
/* ---- the sidebar-foot entry ---- */
|
||||
@@ -24,26 +25,35 @@
|
||||
|
||||
/* ---- the overlay chrome ---- */
|
||||
.lc-ov-backdrop { position: fixed; inset: 0; z-index: 200; background: var(--dsw-alias-bg-mask-1, rgba(0, 0, 0, 0.45)); display: flex; align-items: center; justify-content: center; }
|
||||
.lc-ov-card { width: min(1120px, calc(100vw - 32px)); max-height: min(86vh, 900px); overflow-y: auto; box-sizing: border-box; background: var(--dsw-alias-bg-layer-1); border: 1px solid var(--dsw-alias-border-l2); border-radius: 12px; box-shadow: var(--dsw-shadow-lv3, 0 12px 32px rgba(0, 0, 0, 0.4)); padding: 16px 18px 18px; color: var(--dsw-alias-label-primary); font-size: 13px; container: lc-ov / inline-size; }
|
||||
.lc-ov-head { display: flex; align-items: center; gap: 10px; margin-bottom: 14px; flex-wrap: wrap; }
|
||||
/* The card is a flex column capped at its max-height: the body fills the
|
||||
rest and each column scrolls its own overflow, so browsing sessions never
|
||||
scrolls the insight column out of view. Hidden (not scrollable) here —
|
||||
the scrollers live on the columns, and on narrow widths on the body. */
|
||||
.lc-ov-card { display: flex; flex-direction: column; width: min(1120px, calc(100vw - 32px)); max-height: min(86vh, 900px); overflow: hidden; box-sizing: border-box; background: var(--dsw-alias-bg-layer-1); border: 1px solid var(--dsw-alias-border-l2); border-radius: 12px; box-shadow: var(--dsw-shadow-lv3, 0 12px 32px rgba(0, 0, 0, 0.4)); padding: 16px 18px 18px; color: var(--dsw-alias-label-primary); font-size: 13px; container: lc-ov / inline-size; }
|
||||
.lc-ov-head { flex: none; display: flex; align-items: center; gap: 10px; margin-bottom: 14px; flex-wrap: wrap; }
|
||||
.lc-ov-head-icon { flex: none; }
|
||||
.lc-ov-title { font-weight: 600; font-size: 15px; white-space: nowrap; }
|
||||
.lc-ov-head .lc-modal-close { margin-left: 0; flex: none; }
|
||||
|
||||
/* ---- the mid band: the KPI 2×2 block and the activity heat share one row; flex-wrap folds the heat onto its own row under width pressure ---- */
|
||||
.lc-ov-mid { display: flex; gap: 10px; flex-wrap: wrap; margin-bottom: 14px; }
|
||||
/* border-box keeps the flex bases true outer widths: under the harness's
|
||||
content-box default the padding quietly adds ~30px per card and the row
|
||||
wraps at full width. */
|
||||
.lc-ov-mid > .lc-card { margin-bottom: 0; min-width: 0; box-sizing: border-box; }
|
||||
.lc-ov-kpis { flex: 1 1 320px; min-width: 0; display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-rows: repeat(2, 1fr); gap: 10px; }
|
||||
/* ---- the body: a 2:3 column pair — the insight column (KPI 2×2 over the
|
||||
heat) beside the session column; under width pressure it folds to one
|
||||
full-width column and the body itself becomes the scroller. ---- */
|
||||
.lc-ov-body { flex: 1 1 auto; min-height: 0; display: grid; grid-template-columns: minmax(0, 2fr) minmax(0, 3fr); gap: 10px; }
|
||||
.lc-ov-left { min-height: 0; overflow-y: auto; display: flex; flex-direction: column; gap: 10px; }
|
||||
.lc-ov-right { min-height: 0; overflow-y: auto; }
|
||||
@container lc-ov (max-width: 760px) {
|
||||
.lc-ov-body { grid-template-columns: 1fr; overflow-y: auto; }
|
||||
.lc-ov-left, .lc-ov-right { overflow: visible; }
|
||||
}
|
||||
/* border-box keeps the card tracks true outer widths: under the harness's
|
||||
content-box default the padding quietly adds ~30px per card. */
|
||||
.lc-ov-left > .lc-card { margin-bottom: 0; min-width: 0; box-sizing: border-box; }
|
||||
.lc-ov-kpis { flex: none; display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-rows: repeat(2, 1fr); gap: 10px; }
|
||||
.lc-ov-kpi { align-items: center; padding: 10px 8px; }
|
||||
.lc-ov-kpi .lc-stat-value { font-size: 20px; }
|
||||
.lc-ov-heat-card { flex: 0 1 252px; display: flex; flex-direction: column; }
|
||||
/* Stacked on its own wrapped row (narrow panes), the heat card stretches again — the grid just centers wider. */
|
||||
@container lc-ov (max-width: 680px) {
|
||||
.lc-ov-heat-card { flex: 1 1 252px; }
|
||||
}
|
||||
/* The heat card absorbs the insight column's leftover height (the grid
|
||||
centers inside it) but never shrinks below its own grid. */
|
||||
.lc-ov-heat-card { flex: 1 0 auto; display: flex; flex-direction: column; }
|
||||
|
||||
/*
|
||||
* The activity heatmap: fixed-size square cells (8 week columns ≈ a 200px
|
||||
|
||||
Reference in new issue
Block a user