fix(insights): professional print formatting for the exec report (TASK-1647) (#656)

* fix(insights): professional print formatting for the exec report (TASK-1647)

Polish the /insights/print @media print block (screen view unchanged):
- Print typography: 10pt base so em sizes cascade down; capped headings
  (title 16pt, section 12pt, stat values 14pt, labels 8–9pt).
- Charts: cap canvas height to 150px in print (overrides the inline 200–240px);
  smaller legend.
- Page breaks: break-inside:avoid on status tables + rows + stat cards + chart
  canvases; section headings kept with their content (break-after:avoid).
- Density: tighter report/section/stat/list gaps for a compact report.

Parent: PLAN-1628 (IDEA-1627).

* redesign(insights): charts-up-front dashboard layout + fix LayerCake print overlap (TASK-1647)

- Move the three charts to a compact 2-up grid right after the headline
  stats (charts up front, half-width, not full-bleed).
- Fix the chart-overlap bug: charts were sized via an inline screen height
  that LayerCake measured and cached, then an @media-print rule shrank the
  box afterwards, leaving the SVG drawn at the old range and spilling onto
  the next panel. Now pass small heights (130-150px) identical for screen +
  print so the measurement matches; add overflow:hidden as a clip-safety.
- Cycle time becomes a full-width band: metrics rail + short chart.
- What shipped flows into two columns (dense, fills page width) and is no
  longer kept whole — .shipped-group/.block/.status-table dropped from the
  break-inside:avoid set so a tall section no longer jumps to a fresh page
  and leaves the big blank gap on page 1. Only small atoms (tr, shipped
  line) stay unsplit.
- Compact table cell padding + repeat thead across page breaks.

* fix(insights): stop print charts clipping + shorten throughput X labels (TASK-1647)

- Charts were measured at the 960px app width on screen (~450px per 2-col
  panel) then printed into ~360px columns, so LayerCake's cached width drew
  the SVG too wide and overflow:hidden clipped the right edge (only ~3 of 4
  collection bars showed). Constrain the print page's on-screen report to a
  paper column (720px) so the measured width already fits the printed sheet —
  WYSIWYG, no clipping, all bars visible.
- Throughput X-axis used full ISO dates (2026-05-23) that overlap once several
  buckets share a narrow chart; render compact M/D (or 'M/D Hh' for hourly)
  labels via fmtBucket. maxTicks=8 already thins longer windows.
This commit is contained in:
xarmian
2026-05-29 22:18:58 -04:00
committed by GitHub
parent 333f509274
commit a6667152cc
@@ -131,9 +131,20 @@
{ key: 'completed', label: 'Completed', color: 'var(--chart-4, #10b981)' }
];
// Compact X-axis label for the throughput buckets. Full ISO dates
// ("2026-05-23", ~70px) overlap once several buckets share a narrow chart;
// render "M/D" (day) or "M/D Hh" (hour) instead. Falls back to the raw
// bucket string for week/month or any unrecognised format.
function fmtBucket(b: string): string {
const m = /^(\d{4})-(\d{2})-(\d{2})(?:T(\d{2}))?/.exec(b);
if (!m) return b;
const label = `${Number(m[2])}/${Number(m[3])}`;
return m[4] !== undefined ? `${label} ${Number(m[4])}h` : label;
}
const throughputData = $derived<ChartDatum[]>(
(report?.buckets ?? []).map((b) => ({
bucket: b.bucket,
bucket: fmtBucket(b.bucket),
created: b.created,
completed: b.completed
}))
@@ -241,6 +252,7 @@
{:else if loading && !report}
<div class="state">Loading&hellip;</div>
{:else if report}
<!-- Header -->
<header class="report-header">
<div class="report-title">
@@ -277,7 +289,86 @@
</div>
</section>
<!-- What shipped (centerpiece) -->
<!-- Charts up front: a compact grid of half-width panels intermingled
with the cycle-time metrics, so the report reads like a dashboard
rather than a stack of full-bleed graphs. Heights are passed small
and identical for screen + print (no @media height override) so
LayerCake's cached measurement matches the printed box and the SVG
never overflows onto the next panel. -->
<section class="charts-grid" aria-label="Charts">
<!-- Throughput -->
<div class="panel chart-panel">
<h2 class="panel-title">Throughput</h2>
<p class="panel-sub">Created vs completed per {report.granularity}</p>
{#if noActivity}
<p class="empty">No activity in this period.</p>
{:else}
<BarChart
data={throughputData}
x="bucket"
series={throughputSeries}
height={150}
ariaLabel="Items created versus completed per time bucket"
/>
{/if}
</div>
<!-- Completed by collection -->
<div class="panel chart-panel">
<h2 class="panel-title">Completed by collection</h2>
{#if completedByCollectionData.length === 0}
<p class="empty">Nothing completed in this period.</p>
{:else}
<BarChart
data={completedByCollectionData}
x="collection"
series={[{ key: 'count', label: 'Completed', color: 'var(--chart-4, #10b981)' }]}
height={150}
ariaLabel="Completed items grouped by collection"
/>
{/if}
</div>
<!-- Cycle time (only when there were completions) -->
{#if report.cycle_time.sample_size > 0}
<div class="panel chart-panel cycle-panel">
<h2 class="panel-title">Cycle time</h2>
<p class="panel-sub">Creation to completion</p>
<div class="cycle-body">
<div class="metric-row">
<div class="metric">
<span class="metric-label">Median</span>
<span class="metric-value">{fmtHours(report.cycle_time.median_hours)}</span>
</div>
<div class="metric">
<span class="metric-label">p90</span>
<span class="metric-value">{fmtHours(report.cycle_time.p90_hours)}</span>
</div>
<div class="metric">
<span class="metric-label">Sample</span>
<span class="metric-value">{report.cycle_time.sample_size}</span>
</div>
</div>
{#if cycleTimeData.length > 0}
<div class="cycle-chart">
<BarChart
data={cycleTimeData}
x="collection"
series={[
{ key: 'median_hours', label: 'Median hours', color: 'var(--chart-3, #f59e0b)' }
]}
height={130}
ariaLabel="Median cycle time in hours grouped by collection"
/>
</div>
{/if}
</div>
</div>
{/if}
</section>
<!-- What shipped (centerpiece) — dense multi-column list so the long
completed set flows down the page instead of leaving big gaps. -->
<section class="block">
<h2 class="block-title">What shipped</h2>
{#if shippedGroups.length === 0}
@@ -309,72 +400,6 @@
{/if}
</section>
<!-- Throughput -->
<section class="block chart-block">
<h2 class="block-title">Throughput</h2>
<p class="block-sub">Created vs completed per {report.granularity}</p>
{#if noActivity}
<p class="empty">No activity in this period.</p>
{:else}
<BarChart
data={throughputData}
x="bucket"
series={throughputSeries}
height={240}
ariaLabel="Items created versus completed per time bucket"
/>
{/if}
</section>
<!-- Completed by collection -->
<section class="block chart-block">
<h2 class="block-title">Completed by collection</h2>
{#if completedByCollectionData.length === 0}
<p class="empty">Nothing completed in this period.</p>
{:else}
<BarChart
data={completedByCollectionData}
x="collection"
series={[{ key: 'count', label: 'Completed', color: 'var(--chart-4, #10b981)' }]}
height={220}
ariaLabel="Completed items grouped by collection"
/>
{/if}
</section>
<!-- Cycle time (only when there were completions) -->
{#if report.cycle_time.sample_size > 0}
<section class="block chart-block">
<h2 class="block-title">Cycle time</h2>
<p class="block-sub">Creation to completion</p>
<div class="metric-row">
<div class="metric">
<span class="metric-label">Median</span>
<span class="metric-value">{fmtHours(report.cycle_time.median_hours)}</span>
</div>
<div class="metric">
<span class="metric-label">p90</span>
<span class="metric-value">{fmtHours(report.cycle_time.p90_hours)}</span>
</div>
<div class="metric">
<span class="metric-label">Sample</span>
<span class="metric-value">{report.cycle_time.sample_size}</span>
</div>
</div>
{#if cycleTimeData.length > 0}
<BarChart
data={cycleTimeData}
x="collection"
series={[
{ key: 'median_hours', label: 'Median hours', color: 'var(--chart-3, #f59e0b)' }
]}
height={200}
ariaLabel="Median cycle time in hours grouped by collection"
/>
{/if}
</section>
{/if}
<!-- Status distribution (compact table) -->
{#if statusByCollection.length > 0}
<section class="block">
@@ -443,8 +468,13 @@
}
/* ── Report document ─────────────────────────────────────────────────── */
/* Constrained to paper-column width (≈ Letter/A4 content area) rather than
the app's 960px so the on-screen preview matches the printed sheet. This
is also what keeps the LayerCake charts from clipping in print: each chart
measures its width on screen, so a paper-width screen means that cached
measurement already fits the print column — no off-page overflow. */
.report {
max-width: var(--content-max-width);
max-width: 720px;
margin: 0 auto;
padding: var(--space-8) var(--space-6);
display: flex;
@@ -567,6 +597,51 @@
font-size: 0.9em;
}
/* ── Charts grid (dashboard band, up front) ──────────────────────────── */
.charts-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: var(--space-5);
}
.panel {
display: flex;
flex-direction: column;
gap: var(--space-2);
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--bg-secondary);
padding: var(--space-4);
}
.panel-title {
font-size: 1em;
font-weight: 700;
color: var(--text-primary);
}
.panel-sub {
font-size: 0.78em;
color: var(--text-muted);
margin-top: calc(-1 * var(--space-1));
}
/* Cycle time spans the full grid width as a band: metrics on the left,
its (short) chart on the right, so it doesn't sit as a lonely half-cell. */
.cycle-panel {
grid-column: 1 / -1;
}
.cycle-body {
display: grid;
grid-template-columns: auto 1fr;
align-items: center;
gap: var(--space-6);
}
.cycle-panel .metric-row {
gap: var(--space-5);
flex-direction: column;
flex-wrap: nowrap;
}
.cycle-chart {
min-width: 0;
}
/* ── What shipped ────────────────────────────────────────────────────── */
.shipped {
display: flex;
@@ -735,26 +810,164 @@
display: none !important;
}
/* Smaller print base so the em-based sizes cascade down to a tidy
report scale (body ~10pt). Tighter gap reads as a compact report
rather than a sprawling web page. */
.report {
max-width: none;
margin: 0;
padding: 0;
color: #000;
gap: 1.25rem;
font-size: 9.5pt;
line-height: 1.3;
gap: 0.55rem;
}
.stat-card,
.error-state {
background: transparent;
}
/* Keep logical units intact across page breaks. */
.block,
.stat-row,
.shipped-group,
/* Cap the big screen-scale headings to report proportions. */
.report-title h1 {
font-size: 16pt;
}
.report-subtitle {
font-size: 8.5pt;
}
.period {
font-size: 9.5pt;
}
.generated {
font-size: 8pt;
}
.report-header {
break-inside: avoid;
padding-bottom: 0.4rem;
}
.block-title {
font-size: 12pt;
padding-bottom: 0.25rem;
}
.block-sub {
font-size: 8.5pt;
}
.block {
gap: 0.45rem;
}
/* Compact stat row. */
.stat-row {
gap: 0.5rem;
}
.stat-card {
padding: 0.4rem 0.5rem;
}
.stat-label {
font-size: 8pt;
}
.stat-value {
font-size: 14pt;
}
/* Compact "what shipped" list, flowed into two columns so a long
completed set fills the page width and runs continuously instead of a
tall single column that leaves big vertical gaps. */
.shipped {
gap: 0.6rem;
}
.shipped-list {
column-count: 2;
column-gap: 1.4rem;
}
.shipped-item {
font-size: 8.5pt;
padding: 0.5pt 0;
gap: 0.5rem;
break-inside: avoid;
}
.shipped-ref {
min-width: 4.6em;
}
.shipped-group-name {
font-size: 9.5pt;
}
.shipped-group-count {
font-size: 7.5pt;
}
.metric-value {
font-size: 11pt;
}
.metric-label {
font-size: 8pt;
}
.status-table {
font-size: 8.5pt;
}
/* Compact rows: cap cell padding + line-height so a status table is
physically short. Repeat the header row on every page the table
spills onto (thead as a table-header-group), and right-size the
grid so two compact tables sit side by side instead of one tall stack. */
.status-tables {
gap: 0.6rem 1.2rem;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
}
.status-table th,
.status-table td {
padding: 0.5pt 5pt;
line-height: 1.2;
}
.status-table thead {
display: table-header-group;
}
/* Charts grid for print. Tighten the gap and de-emphasise the panel
chrome (no fill / border on paper). The chart heights are passed as
small props (≈130150px) identical to screen, so we DON'T override
.canvas height here — that was the cause of the overlap: shrinking the
box after LayerCake measured the screen height left the SVG drawn at
the old range, spilling over the next panel. `overflow: hidden` clips
any residual sub-pixel spill so panels can never bleed into each
other. */
.charts-grid {
gap: 0.5rem 1.2rem;
}
.panel {
padding: 0;
border: none;
background: transparent;
gap: 0.2rem;
}
.panel-title {
font-size: 11pt;
}
.panel-sub {
font-size: 8pt;
}
.chart-panel :global(.canvas) {
overflow: hidden;
}
.chart-panel :global(.legend) {
font-size: 8pt;
margin-bottom: 0.2rem;
}
/* Keep small logical units intact across page breaks — a stat row, a
stat card, a chart panel, the header. Deliberately DO NOT add
`.block`, `.status-table`, or `.shipped-group` here: those can be
taller than a page, and keeping them whole forces a jump to the next
page that leaves a big blank gap (the page-1 symptom). They're allowed
to flow across page boundaries instead; only the small atoms inside
them (a table `tr`, a shipped line) are kept from splitting. */
.stat-row,
.stat-card,
.report-header,
.chart-panel,
.status-table tr,
.chart-panel :global(.canvas) {
break-inside: avoid;
}
.block-title,
.report-title h1 {
break-after: avoid;
}