mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-24 11:26:34 +00:00
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:
@@ -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…</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 (≈130–150px) 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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user