diff --git a/web/src/routes/[username]/[workspace]/insights/print/+page.svelte b/web/src/routes/[username]/[workspace]/insights/print/+page.svelte index d23e7b68..99845c6f 100644 --- a/web/src/routes/[username]/[workspace]/insights/print/+page.svelte +++ b/web/src/routes/[username]/[workspace]/insights/print/+page.svelte @@ -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( (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}
Loading…
{:else if report} +
@@ -277,7 +289,86 @@
- + +
+ +
+

Throughput

+

Created vs completed per {report.granularity}

+ {#if noActivity} +

No activity in this period.

+ {:else} + + {/if} +
+ + +
+

Completed by collection

+ {#if completedByCollectionData.length === 0} +

Nothing completed in this period.

+ {:else} + + {/if} +
+ + + {#if report.cycle_time.sample_size > 0} +
+

Cycle time

+

Creation to completion

+
+
+
+ Median + {fmtHours(report.cycle_time.median_hours)} +
+
+ p90 + {fmtHours(report.cycle_time.p90_hours)} +
+
+ Sample + {report.cycle_time.sample_size} +
+
+ {#if cycleTimeData.length > 0} +
+ +
+ {/if} +
+
+ {/if} +
+ +

What shipped

{#if shippedGroups.length === 0} @@ -309,72 +400,6 @@ {/if}
- -
-

Throughput

-

Created vs completed per {report.granularity}

- {#if noActivity} -

No activity in this period.

- {:else} - - {/if} -
- - -
-

Completed by collection

- {#if completedByCollectionData.length === 0} -

Nothing completed in this period.

- {:else} - - {/if} -
- - - {#if report.cycle_time.sample_size > 0} -
-

Cycle time

-

Creation to completion

-
-
- Median - {fmtHours(report.cycle_time.median_hours)} -
-
- p90 - {fmtHours(report.cycle_time.p90_hours)} -
-
- Sample - {report.cycle_time.sample_size} -
-
- {#if cycleTimeData.length > 0} - - {/if} -
- {/if} - {#if statusByCollection.length > 0}
@@ -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; }