briefing 0.2.0__tar.gz → 0.2.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. {briefing-0.2.0 → briefing-0.2.1}/CHANGELOG.md +14 -1
  2. {briefing-0.2.0 → briefing-0.2.1}/PKG-INFO +1 -1
  3. {briefing-0.2.0 → briefing-0.2.1}/src/briefing/__init__.py +1 -1
  4. {briefing-0.2.0 → briefing-0.2.1}/src/briefing/lab/_blocks.py +2 -0
  5. {briefing-0.2.0 → briefing-0.2.1}/src/briefing/lab/_render_calendar.py +12 -4
  6. {briefing-0.2.0 → briefing-0.2.1}/src/briefing/lab/static/calendar.js +26 -1
  7. {briefing-0.2.0 → briefing-0.2.1}/src/briefing/static/report.css +16 -6
  8. briefing-0.2.1/tests/test_browser/test_calendar.py +147 -0
  9. briefing-0.2.0/tests/test_browser/test_calendar.py +0 -81
  10. {briefing-0.2.0 → briefing-0.2.1}/.gitignore +0 -0
  11. {briefing-0.2.0 → briefing-0.2.1}/LICENSE +0 -0
  12. {briefing-0.2.0 → briefing-0.2.1}/README.md +0 -0
  13. {briefing-0.2.0 → briefing-0.2.1}/noxfile.py +0 -0
  14. {briefing-0.2.0 → briefing-0.2.1}/pyproject.toml +0 -0
  15. {briefing-0.2.0 → briefing-0.2.1}/src/briefing/__main__.py +0 -0
  16. {briefing-0.2.0 → briefing-0.2.1}/src/briefing/_cli.py +0 -0
  17. {briefing-0.2.0 → briefing-0.2.1}/src/briefing/_error.py +0 -0
  18. {briefing-0.2.0 → briefing-0.2.1}/src/briefing/_frames.py +0 -0
  19. {briefing-0.2.0 → briefing-0.2.1}/src/briefing/_preview.py +0 -0
  20. {briefing-0.2.0 → briefing-0.2.1}/src/briefing/blocks/__init__.py +0 -0
  21. {briefing-0.2.0 → briefing-0.2.1}/src/briefing/blocks/asset.py +0 -0
  22. {briefing-0.2.0 → briefing-0.2.1}/src/briefing/blocks/banner.py +0 -0
  23. {briefing-0.2.0 → briefing-0.2.1}/src/briefing/blocks/base.py +0 -0
  24. {briefing-0.2.0 → briefing-0.2.1}/src/briefing/blocks/layout.py +0 -0
  25. {briefing-0.2.0 → briefing-0.2.1}/src/briefing/blocks/text.py +0 -0
  26. {briefing-0.2.0 → briefing-0.2.1}/src/briefing/export/__init__.py +0 -0
  27. {briefing-0.2.0 → briefing-0.2.1}/src/briefing/export/_image.py +0 -0
  28. {briefing-0.2.0 → briefing-0.2.1}/src/briefing/export/_pdf.py +0 -0
  29. {briefing-0.2.0 → briefing-0.2.1}/src/briefing/export/_render.py +0 -0
  30. {briefing-0.2.0 → briefing-0.2.1}/src/briefing/lab/__init__.py +0 -0
  31. {briefing-0.2.0 → briefing-0.2.1}/src/briefing/lab/_render_datadive.py +0 -0
  32. {briefing-0.2.0 → briefing-0.2.1}/src/briefing/lab/_render_profile.py +0 -0
  33. {briefing-0.2.0 → briefing-0.2.1}/src/briefing/lab/static/lab.js +0 -0
  34. {briefing-0.2.0 → briefing-0.2.1}/src/briefing/py.typed +0 -0
  35. {briefing-0.2.0 → briefing-0.2.1}/src/briefing/renderers/__init__.py +0 -0
  36. {briefing-0.2.0 → briefing-0.2.1}/src/briefing/renderers/formatting.py +0 -0
  37. {briefing-0.2.0 → briefing-0.2.1}/src/briefing/renderers/formula.py +0 -0
  38. {briefing-0.2.0 → briefing-0.2.1}/src/briefing/renderers/html.py +0 -0
  39. {briefing-0.2.0 → briefing-0.2.1}/src/briefing/renderers/normalize.py +0 -0
  40. {briefing-0.2.0 → briefing-0.2.1}/src/briefing/renderers/plot.py +0 -0
  41. {briefing-0.2.0 → briefing-0.2.1}/src/briefing/renderers/registry.py +0 -0
  42. {briefing-0.2.0 → briefing-0.2.1}/src/briefing/renderers/script_json.py +0 -0
  43. {briefing-0.2.0 → briefing-0.2.1}/src/briefing/renderers/table.py +0 -0
  44. {briefing-0.2.0 → briefing-0.2.1}/src/briefing/static/export.css +0 -0
  45. {briefing-0.2.0 → briefing-0.2.1}/src/briefing/static/export.js +0 -0
  46. {briefing-0.2.0 → briefing-0.2.1}/src/briefing/static/report.js +0 -0
  47. {briefing-0.2.0 → briefing-0.2.1}/src/briefing/templates/report.html.j2 +0 -0
  48. {briefing-0.2.0 → briefing-0.2.1}/tests/__init__.py +0 -0
  49. {briefing-0.2.0 → briefing-0.2.1}/tests/banner_images.py +0 -0
  50. {briefing-0.2.0 → briefing-0.2.1}/tests/compat_reports.py +0 -0
  51. {briefing-0.2.0 → briefing-0.2.1}/tests/conftest.py +0 -0
  52. {briefing-0.2.0 → briefing-0.2.1}/tests/contract.py +0 -0
  53. {briefing-0.2.0 → briefing-0.2.1}/tests/html_helpers.py +0 -0
  54. {briefing-0.2.0 → briefing-0.2.1}/tests/image_helpers.py +0 -0
  55. {briefing-0.2.0 → briefing-0.2.1}/tests/pdf_reader.py +0 -0
  56. {briefing-0.2.0 → briefing-0.2.1}/tests/snapshots/compat_0_1_2/blocks.body.html +0 -0
  57. {briefing-0.2.0 → briefing-0.2.1}/tests/snapshots/compat_0_1_2/pages.body.html +0 -0
  58. {briefing-0.2.0 → briefing-0.2.1}/tests/test_blocks/__init__.py +0 -0
  59. {briefing-0.2.0 → briefing-0.2.1}/tests/test_blocks/test_banner.py +0 -0
  60. {briefing-0.2.0 → briefing-0.2.1}/tests/test_blocks/test_base.py +0 -0
  61. {briefing-0.2.0 → briefing-0.2.1}/tests/test_blocks/test_layout.py +0 -0
  62. {briefing-0.2.0 → briefing-0.2.1}/tests/test_blocks/test_text.py +0 -0
  63. {briefing-0.2.0 → briefing-0.2.1}/tests/test_browser/__init__.py +0 -0
  64. {briefing-0.2.0 → briefing-0.2.1}/tests/test_browser/conftest.py +0 -0
  65. {briefing-0.2.0 → briefing-0.2.1}/tests/test_browser/test_banner.py +0 -0
  66. {briefing-0.2.0 → briefing-0.2.1}/tests/test_browser/test_bignumber.py +0 -0
  67. {briefing-0.2.0 → briefing-0.2.1}/tests/test_browser/test_cli_export.py +0 -0
  68. {briefing-0.2.0 → briefing-0.2.1}/tests/test_browser/test_code.py +0 -0
  69. {briefing-0.2.0 → briefing-0.2.1}/tests/test_browser/test_datadive.py +0 -0
  70. {briefing-0.2.0 → briefing-0.2.1}/tests/test_browser/test_datatable.py +0 -0
  71. {briefing-0.2.0 → briefing-0.2.1}/tests/test_browser/test_export_image.py +0 -0
  72. {briefing-0.2.0 → briefing-0.2.1}/tests/test_browser/test_export_menu.py +0 -0
  73. {briefing-0.2.0 → briefing-0.2.1}/tests/test_browser/test_export_pdf.py +0 -0
  74. {briefing-0.2.0 → briefing-0.2.1}/tests/test_browser/test_formula.py +0 -0
  75. {briefing-0.2.0 → briefing-0.2.1}/tests/test_browser/test_layout.py +0 -0
  76. {briefing-0.2.0 → briefing-0.2.1}/tests/test_browser/test_notebook.py +0 -0
  77. {briefing-0.2.0 → briefing-0.2.1}/tests/test_browser/test_plots.py +0 -0
  78. {briefing-0.2.0 → briefing-0.2.1}/tests/test_browser/test_print.py +0 -0
  79. {briefing-0.2.0 → briefing-0.2.1}/tests/test_browser/test_spacing.py +0 -0
  80. {briefing-0.2.0 → briefing-0.2.1}/tests/test_browser/test_text.py +0 -0
  81. {briefing-0.2.0 → briefing-0.2.1}/tests/test_browser/test_toggle_theme.py +0 -0
  82. {briefing-0.2.0 → briefing-0.2.1}/tests/test_cli.py +0 -0
  83. {briefing-0.2.0 → briefing-0.2.1}/tests/test_compat.py +0 -0
  84. {briefing-0.2.0 → briefing-0.2.1}/tests/test_contract.py +0 -0
  85. {briefing-0.2.0 → briefing-0.2.1}/tests/test_deprecations.py +0 -0
  86. {briefing-0.2.0 → briefing-0.2.1}/tests/test_docs_gallery.py +0 -0
  87. {briefing-0.2.0 → briefing-0.2.1}/tests/test_docstrings.py +0 -0
  88. {briefing-0.2.0 → briefing-0.2.1}/tests/test_export.py +0 -0
  89. {briefing-0.2.0 → briefing-0.2.1}/tests/test_frames.py +0 -0
  90. {briefing-0.2.0 → briefing-0.2.1}/tests/test_lab/__init__.py +0 -0
  91. {briefing-0.2.0 → briefing-0.2.1}/tests/test_lab/test_calendar.py +0 -0
  92. {briefing-0.2.0 → briefing-0.2.1}/tests/test_lab/test_datadive.py +0 -0
  93. {briefing-0.2.0 → briefing-0.2.1}/tests/test_lab/test_lab_boundary.py +0 -0
  94. {briefing-0.2.0 → briefing-0.2.1}/tests/test_lab/test_profile.py +0 -0
  95. {briefing-0.2.0 → briefing-0.2.1}/tests/test_no_viz_deps.py +0 -0
  96. {briefing-0.2.0 → briefing-0.2.1}/tests/test_pdf_reader.py +0 -0
  97. {briefing-0.2.0 → briefing-0.2.1}/tests/test_preview.py +0 -0
  98. {briefing-0.2.0 → briefing-0.2.1}/tests/test_readme.py +0 -0
  99. {briefing-0.2.0 → briefing-0.2.1}/tests/test_renderers/__init__.py +0 -0
  100. {briefing-0.2.0 → briefing-0.2.1}/tests/test_renderers/test_export_menu.py +0 -0
  101. {briefing-0.2.0 → briefing-0.2.1}/tests/test_renderers/test_formula.py +0 -0
  102. {briefing-0.2.0 → briefing-0.2.1}/tests/test_renderers/test_html.py +0 -0
  103. {briefing-0.2.0 → briefing-0.2.1}/tests/test_renderers/test_normalize.py +0 -0
  104. {briefing-0.2.0 → briefing-0.2.1}/tests/test_renderers/test_plot.py +0 -0
  105. {briefing-0.2.0 → briefing-0.2.1}/tests/test_renderers/test_reproducible.py +0 -0
  106. {briefing-0.2.0 → briefing-0.2.1}/tests/test_renderers/test_table.py +0 -0
@@ -10,6 +10,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
10
10
 
11
11
  ## [Unreleased]
12
12
 
13
+ ## [0.2.1] - 2026-11-04
14
+
15
+ ### Changed
16
+
17
+ - `lab.CalendarHeatmap` fits the width available to it, the report's or its `Group`
18
+ column's, instead of a fixed size: a year fills a full-width report, and in a column it
19
+ shrinks to fit instead of widening the column and overflowing the page. Cells stay
20
+ between 0.6× and 2× their natural 11 px (a short range stops at 2×; below 0.6× the
21
+ calendar scrolls inside its block); labels change size at half that rate and legend
22
+ swatches follow the cells. When printed it shrinks to fit the sheet instead of being cut
23
+ off (#106).
24
+
13
25
  ## [0.2.0] - 2026-11-04
14
26
 
15
27
  ### Added
@@ -199,7 +211,8 @@ First public release.
199
211
  inherits its base's renderer.
200
212
  - Ships inline type hints (`py.typed`). Python 3.11+.
201
213
 
202
- [Unreleased]: https://github.com/jeanmidevacc/briefing/compare/v0.2.0...HEAD
214
+ [Unreleased]: https://github.com/jeanmidevacc/briefing/compare/v0.2.1...HEAD
215
+ [0.2.1]: https://github.com/jeanmidevacc/briefing/compare/v0.2.0...v0.2.1
203
216
  [0.2.0]: https://github.com/jeanmidevacc/briefing/compare/v0.1.2...v0.2.0
204
217
  [0.1.2]: https://github.com/jeanmidevacc/briefing/compare/v0.1.1...v0.1.2
205
218
  [0.1.1]: https://github.com/jeanmidevacc/briefing/compare/v0.1.0...v0.1.1
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: briefing
3
- Version: 0.2.0
3
+ Version: 0.2.1
4
4
  Summary: Build beautiful self-contained HTML reports from Python analysis
5
5
  Project-URL: Homepage, https://github.com/jeanmidevacc/briefing
6
6
  Project-URL: Documentation, https://briefing.readthedocs.io
@@ -134,7 +134,7 @@ def stringify(
134
134
  return render_report(wrapped, name=name, formatting=formatting, now=now)
135
135
 
136
136
 
137
- __version__ = "0.2.0"
137
+ __version__ = "0.2.1"
138
138
 
139
139
  __all__: list[str] = [
140
140
  # error
@@ -161,6 +161,8 @@ class CalendarHeatmap(Block):
161
161
  Counts the rows per day, or aggregates a value column, and colours each day.
162
162
  Days without data look different from days with a zero; the grid runs
163
163
  continuously across a year boundary, with month labels and a year marker.
164
+ The calendar fits the width available (the report or a ``Group`` column),
165
+ between 0.6x and 2x its natural size; below that it scrolls sideways.
164
166
 
165
167
  Args:
166
168
  df: The data, as any dataframe briefing accepts (pandas, polars, pyarrow…).
@@ -32,6 +32,16 @@ _WEEKDAYS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
32
32
  _MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]
33
33
 
34
34
 
35
+ def _svg_open(cls: str, width: int, height: int, label: str) -> str:
36
+ """Open a calendar SVG that scales with its block (#106).
37
+
38
+ ``width``/``height`` are the natural size. The CSS stretches the SVG to the block's
39
+ width, between its natural width (``--bf-cal-w``) and twice that.
40
+ """
41
+ return (f'<svg class="{cls}" xmlns="http://www.w3.org/2000/svg" width="{width}" height="{height}" '
42
+ f'viewBox="0 0 {width} {height}" style="--bf-cal-w: {width}px" role="img" aria-label="{label}">')
43
+
44
+
35
45
  @dataclass(frozen=True)
36
46
  class _Event:
37
47
  start: dt.date
@@ -215,8 +225,7 @@ def _days_svg(
215
225
  grid_h = 7 * _STEP
216
226
  width = _LEFT + weeks * _STEP
217
227
  height = _TOP + grid_h + (len(lanes) * _LANE + 6 if lanes else 0)
218
- parts = [f'<svg class="bf-cal__days" xmlns="http://www.w3.org/2000/svg" width="{width}" height="{height}" '
219
- f'viewBox="0 0 {width} {height}" role="img" aria-label="Calendar heatmap">']
228
+ parts = [_svg_open("bf-cal__days", width, height, "Calendar heatmap")]
220
229
 
221
230
  for row in (0, 2, 4): # Mon, Wed, Fri
222
231
  name = _WEEKDAYS[(row - shift) % 7]
@@ -352,8 +361,7 @@ _HOUR_STEP = 16
352
361
  def _hours_svg(block: CalendarHeatmap, data: dict[t.Any, float], colour: Callable[[float], tuple[str, str]]) -> str:
353
362
  shift = 0 if block.week_start == "monday" else 1
354
363
  width, height = _LEFT + 24 * _HOUR_STEP, 18 + 7 * _HOUR_STEP
355
- parts = [f'<svg class="bf-cal__hours" xmlns="http://www.w3.org/2000/svg" width="{width}" height="{height}" '
356
- f'viewBox="0 0 {width} {height}" role="img" aria-label="Activity by weekday and hour">']
364
+ parts = [_svg_open("bf-cal__hours", width, height, "Activity by weekday and hour")]
357
365
  for hour in range(0, 24, 3):
358
366
  parts.append(f'<text class="bf-cal__month" x="{_LEFT + hour * _HOUR_STEP}" y="12">{hour:02d}</text>')
359
367
  what = _what(block)
@@ -1,7 +1,32 @@
1
- /* briefing lab: CalendarHeatmap on_click="rows" — click a day or an hour to list its rows. Vanilla JS. */
1
+ /* briefing lab: CalendarHeatmap — sizing, and on_click="rows" (click a day or an hour to list its rows). Vanilla JS. */
2
2
  (function () {
3
3
  'use strict';
4
4
 
5
+ // The SVGs fit the width of their block (CSS, #106). Publish the scale k on the view: legend
6
+ // swatches follow the cells (--bf-cal-k), and labels change size at half the cells' rate, so
7
+ // they stay readable when the calendar grows or shrinks (--bf-cal-text, in the SVG's units).
8
+ function measure(view) {
9
+ var svg = view.querySelector('svg.bf-cal__days, svg.bf-cal__hours');
10
+ var natural = svg ? parseFloat(svg.getAttribute('width')) : 0;
11
+ var shown = svg ? svg.getBoundingClientRect().width : 0;
12
+ if (!(natural > 0 && shown > 0)) return; // a hidden tab: measured once it is shown
13
+ var k = shown / natural;
14
+ view.style.setProperty('--bf-cal-k', String(k));
15
+ view.style.setProperty('--bf-cal-text', String((1 + k) / (2 * k)));
16
+ }
17
+ document.querySelectorAll('.bf-calendar').forEach(function (figure) {
18
+ var views = figure.querySelectorAll('.bf-cal__views > .bf-select__panel');
19
+ views = views.length ? Array.prototype.slice.call(views) : [figure];
20
+ views.forEach(function (view) {
21
+ measure(view);
22
+ if (window.ResizeObserver) {
23
+ new ResizeObserver(function () { measure(view); }).observe(view);
24
+ } else {
25
+ window.addEventListener('resize', function () { measure(view); });
26
+ }
27
+ });
28
+ });
29
+
5
30
  document.querySelectorAll('.bf-calendar').forEach(function (figure) {
6
31
  var dataEl = figure.querySelector('script.bf-cal__rows');
7
32
  if (!dataEl) return;
@@ -706,13 +706,18 @@ body {
706
706
  }
707
707
 
708
708
  /* ── lab.CalendarHeatmap ─────────────────────────────────────────────────── */
709
- .bf-calendar { margin: 0; }
709
+ .bf-calendar { margin: 0; min-width: 0; } /* may shrink inside a Group column (#106) */
710
710
  .bf-cal__calendar + .bf-cal__calendar { margin-top: 1rem; }
711
711
  .bf-cal__group { font-weight: 600; font-size: 0.9rem; margin-bottom: 0.25rem; }
712
712
  .bf-cal__scroll { overflow-x: auto; }
713
- .bf-cal__days, .bf-cal__hours { display: block; max-width: none; font-family: var(--bf-font); }
714
- .bf-cal__days text, .bf-cal__hours text { fill: var(--bf-muted); font-size: 9px; }
715
- .bf-cal__days .bf-cal__year { fill: var(--bf-text); font-weight: 700; font-size: 10px; }
713
+ .bf-cal__days, .bf-cal__hours { display: block; font-family: var(--bf-font);
714
+ /* Fit the width available (report or Group column) from 0.6x to 2x the natural size (#106);
715
+ below 0.6x the calendar scrolls inside its block. */
716
+ width: 100%; height: auto; min-width: calc(0.6 * var(--bf-cal-w)); max-width: calc(2 * var(--bf-cal-w)); }
717
+ /* calendar.js sets --bf-cal-text so labels change size at half the cells' rate; without it,
718
+ labels scale with the SVG. */
719
+ .bf-cal__days text, .bf-cal__hours text { fill: var(--bf-muted); font-size: calc(9px * var(--bf-cal-text, 1)); }
720
+ .bf-cal__days .bf-cal__year { fill: var(--bf-text); font-weight: 700; font-size: calc(10px * var(--bf-cal-text, 1)); }
716
721
  .bf-cal__year-line { stroke: var(--bf-muted); stroke-dasharray: 2 2; }
717
722
  .bf-cal__day--zero, .bf-cal__hour--zero { fill: var(--bf-border); }
718
723
  .bf-cal__day--empty, .bf-cal__hour--empty { fill: none; stroke: var(--bf-border); stroke-width: 1; stroke-dasharray: 2 1.5; }
@@ -720,11 +725,13 @@ body {
720
725
  .bf-cal__day--below { stroke: #dc2626; stroke-width: 1.5; stroke-dasharray: none; }
721
726
  .bf-cal__event rect { fill: color-mix(in oklab, var(--bf-accent) 25%, transparent); stroke: var(--bf-accent); }
722
727
  .bf-cal__event circle { fill: var(--bf-accent); }
723
- .bf-cal__event text { fill: var(--bf-text); font-size: 9px; }
728
+ .bf-cal__event text { fill: var(--bf-text); font-size: calc(9px * var(--bf-cal-text, 1)); }
724
729
  .bf-cal__legend { display: flex; flex-wrap: wrap; gap: 0.25rem 1.25rem; align-items: center;
725
730
  margin-top: 0.5rem; font-size: 0.75rem; color: var(--bf-muted); }
726
731
  .bf-cal__legend-item { display: inline-flex; align-items: center; gap: 3px; }
727
- .bf-cal__swatch { display: inline-block; width: 11px; height: 11px; border-radius: 2px; }
732
+ .bf-cal__swatch { display: inline-block; width: max(8px, 11px * var(--bf-cal-k, 1));
733
+ height: max(8px, 11px * var(--bf-cal-k, 1));
734
+ border-radius: 2px; }
728
735
  .bf-cal__swatch--zero { background: var(--bf-border); }
729
736
  .bf-cal__swatch--empty { border: 1px dashed var(--bf-border); }
730
737
  .bf-cal__swatch--below { border: 1.5px solid #dc2626; }
@@ -777,4 +784,7 @@ html.bf-printing .bf-print-label {
777
784
  .bf-blocks > .bf-select:only-child > .bf-select__panel + .bf-print-label { break-before: page; }
778
785
  .bf-plot, .bf-alert, .bf-bignumber, .bf-formula, .bf-code, .bf-profile__card,
779
786
  .bf-group:has(> .bf-bignumber) { break-inside: avoid; }
787
+ /* A calendar may shrink to fit the sheet instead of being clipped (#106). */
788
+ .bf-cal__days, .bf-cal__hours { min-width: 0; --bf-cal-text: 1; }
789
+ .bf-cal__swatch { width: 11px; height: 11px; }
780
790
  }
@@ -0,0 +1,147 @@
1
+ """lab.CalendarHeatmap as a reader sees it (#19)."""
2
+ from __future__ import annotations
3
+
4
+ import pandas as pd
5
+ import pytest
6
+ from playwright.sync_api import Page
7
+
8
+ import briefing as bf
9
+ from tests.test_browser.conftest import Viewer
10
+
11
+
12
+ @pytest.mark.parametrize("theme", ["default", "dark"])
13
+ def test_zero_no_data_and_below_target_are_visibly_different(viewer: Viewer, df_pipeline_log: pd.DataFrame,
14
+ theme: str) -> None:
15
+ formatting = bf.Formatting.dark() if theme == "dark" else bf.Formatting()
16
+ block = bf.lab.CalendarHeatmap(df_pipeline_log, date="run_at", value="rows", expected=500)
17
+ page = viewer.open(bf.Briefing(block), formatting=formatting)
18
+
19
+ def look(day: str) -> dict[str, str]:
20
+ style: dict[str, str] = page.locator(f'rect[data-date="{day}"]').evaluate(
21
+ "e => { const s = getComputedStyle(e); return {fill: s.fill, stroke: s.stroke, dash: s.strokeDasharray}; }")
22
+ return style
23
+
24
+ background = page.evaluate("() => getComputedStyle(document.body).backgroundColor")
25
+ zero, empty, ok = look("2025-12-04"), look("2026-01-17"), look("2025-11-18")
26
+ assert zero["fill"] not in ("none", background) # a zero is a filled grey cell
27
+ assert empty["fill"] == "none" # no data is an outline only
28
+ assert look("2026-01-03")["stroke"] == "rgb(220, 38, 38)" # a failed run is flagged red
29
+ assert ok["stroke"] in ("none", "") # a normal day has no outline
30
+
31
+
32
+ def _year() -> pd.DataFrame:
33
+ """One run a day through 2025: 53 week columns, a calendar 774 px wide at its natural size."""
34
+ days = pd.date_range("2025-01-01", "2025-12-31", freq="D")
35
+ return pd.DataFrame({"run_at": days, "rows": range(len(days))})
36
+
37
+
38
+ def _width(page: Page, selector: str) -> float:
39
+ box = page.locator(selector).first.bounding_box()
40
+ assert box is not None
41
+ return float(box["width"])
42
+
43
+
44
+ def test_a_year_fills_the_width_of_its_block(viewer: Viewer) -> None:
45
+ page = viewer.open(bf.Briefing(bf.lab.CalendarHeatmap(_year(), date="run_at")))
46
+ assert _width(page, "svg.bf-cal__days") == pytest.approx(_width(page, ".bf-cal__scroll"), abs=1)
47
+ assert _width(page, 'rect[data-date="2025-06-02"]') > 14 # cells grew from their natural 11 px
48
+
49
+
50
+ def test_a_short_range_grows_to_twice_its_natural_size(viewer: Viewer, df_pipeline_log: pd.DataFrame) -> None:
51
+ page = viewer.open(bf.Briefing(bf.lab.CalendarHeatmap(df_pipeline_log, date="run_at", views=("days", "hours"))))
52
+ natural = float(page.locator("svg.bf-cal__days").get_attribute("width") or 0)
53
+ assert _width(page, "svg.bf-cal__days") == pytest.approx(2 * natural, abs=1)
54
+ assert _width(page, 'rect[data-date="2026-01-05"]') == pytest.approx(22, abs=0.5)
55
+ page.get_by_role("tab", name="Hours").click() # a hidden view is measured once it is shown
56
+ natural = float(page.locator("svg.bf-cal__hours").get_attribute("width") or 0)
57
+ assert _width(page, "svg.bf-cal__hours") == pytest.approx(2 * natural, abs=1)
58
+
59
+
60
+ def test_labels_grow_at_half_the_rate_and_swatches_match_cells(viewer: Viewer,
61
+ df_pipeline_log: pd.DataFrame) -> None:
62
+ page = viewer.open(bf.Briefing(bf.lab.CalendarHeatmap(df_pipeline_log, date="run_at")))
63
+ label = page.locator("svg.bf-cal__days text.bf-cal__weekday").first.bounding_box()
64
+ assert label is not None and label["height"] < 20 # 9 px text at 2x would be about 24 px tall
65
+ cell = _width(page, 'rect[data-date="2026-01-05"]')
66
+ assert _width(page, ".bf-cal__swatch:not(.bf-cal__swatch--event-day)") == pytest.approx(cell, abs=0.5)
67
+
68
+
69
+ def test_a_calendar_in_a_group_fits_its_column(viewer: Viewer, df_pipeline_log: pd.DataFrame) -> None:
70
+ year = bf.lab.CalendarHeatmap(_year(), date="run_at")
71
+ page = viewer.open(bf.Briefing(bf.Group(year, bf.lab.CalendarHeatmap(df_pipeline_log, date="run_at"), columns=2)))
72
+ columns = page.locator(".bf-group > .bf-calendar")
73
+ first, second = columns.nth(0).bounding_box(), columns.nth(1).bounding_box()
74
+ assert first is not None and second is not None
75
+ assert first["width"] == pytest.approx(second["width"], abs=1) # the year no longer widens its column
76
+ assert _width(page, "svg.bf-cal__days") == pytest.approx(first["width"], abs=1)
77
+ assert _width(page, 'rect[data-date="2025-06-02"]') < 11 # shrunk to fit
78
+ assert page.evaluate("() => document.documentElement.scrollWidth <= window.innerWidth")
79
+
80
+
81
+ def test_a_narrow_screen_shrinks_then_scrolls(viewer: Viewer) -> None:
82
+ viewer.page.set_viewport_size({"width": 375, "height": 800})
83
+ page = viewer.open(bf.Briefing(bf.lab.CalendarHeatmap(_year(), date="run_at")))
84
+ assert _width(page, 'rect[data-date="2025-06-02"]') == pytest.approx(0.6 * 11, abs=0.3) # the floor
85
+ overflow = page.locator(".bf-cal__scroll").evaluate("e => e.scrollWidth > e.clientWidth")
86
+ assert overflow
87
+ assert page.evaluate("() => document.documentElement.scrollWidth <= window.innerWidth") # inside the block
88
+ label = page.locator("svg.bf-cal__days text.bf-cal__weekday").first.bounding_box()
89
+ assert label is not None and label["height"] > 6 # labels shrink at half the rate
90
+
91
+
92
+ def test_a_year_prints_without_clipping(viewer: Viewer) -> None:
93
+ viewer.page.set_viewport_size({"width": 794, "height": 1123}) # A4 at 96 dpi
94
+ page = viewer.open(bf.Briefing(bf.lab.CalendarHeatmap(_year(), date="run_at")))
95
+ page.emulate_media(media="print")
96
+ page.evaluate("window.dispatchEvent(new Event('beforeprint'))")
97
+ scroll = page.locator(".bf-cal__scroll").bounding_box()
98
+ last = page.locator('rect[data-date="2025-12-31"]').bounding_box()
99
+ assert scroll is not None and last is not None
100
+ assert last["x"] + last["width"] <= scroll["x"] + scroll["width"] + 0.5
101
+
102
+
103
+ def test_a_tab_switches_between_the_day_and_hour_views(viewer: Viewer, df_pipeline_log: pd.DataFrame) -> None:
104
+ page = viewer.open(bf.Briefing(bf.lab.CalendarHeatmap(df_pipeline_log, date="run_at", views=("days", "hours"))))
105
+ days, hours = page.locator("svg.bf-cal__days"), page.locator("svg.bf-cal__hours")
106
+ assert days.is_visible() and not hours.is_visible()
107
+ page.get_by_role("tab", name="Hours").click()
108
+ assert hours.is_visible() and not days.is_visible()
109
+ assert page.locator("rect.bf-cal__hour").count() == 7 * 24
110
+
111
+
112
+ def test_hour_view_labels_are_small(viewer: Viewer, df_pipeline_log: pd.DataFrame) -> None:
113
+ page = viewer.open(bf.Briefing(bf.lab.CalendarHeatmap(df_pipeline_log, date="run_at", views=("hours",))))
114
+ box = page.locator("svg.bf-cal__hours text").first.bounding_box()
115
+ assert box is not None and box["height"] < 20 # not scaled up with the 2x grid
116
+
117
+
118
+ def test_clicking_a_day_lists_its_rows(viewer: Viewer, df_pipeline_log: pd.DataFrame) -> None:
119
+ page = viewer.open(bf.Briefing(bf.lab.CalendarHeatmap(df_pipeline_log, date="run_at", on_click="rows")))
120
+ panel = page.locator(".bf-cal__panel")
121
+ assert not panel.is_visible()
122
+ page.locator('rect[data-date="2025-12-07"]').click()
123
+ assert panel.is_visible()
124
+ assert "2025-12-07" in panel.locator(".bf-cal__panel-title").inner_text()
125
+ assert panel.locator("tbody tr").count() == 2
126
+ assert panel.locator("thead th").all_inner_texts() == ["run_at", "team", "rows"]
127
+ panel.get_by_role("button", name="Close").click()
128
+ assert not panel.is_visible()
129
+
130
+
131
+ def test_keyboard_opens_the_rows_of_an_hour(viewer: Viewer, df_pipeline_log: pd.DataFrame) -> None:
132
+ block = bf.lab.CalendarHeatmap(df_pipeline_log, date="run_at", views=("hours",), on_click="rows")
133
+ page = viewer.open(bf.Briefing(block))
134
+ page.locator('rect[data-hour="0-6"]').focus()
135
+ page.keyboard.press("Enter")
136
+ assert "Mon 06:00" in page.locator(".bf-cal__panel-title").inner_text()
137
+
138
+
139
+ def test_capped_cells_say_so_and_values_stay_text(viewer: Viewer) -> None:
140
+ df = pd.DataFrame({"run_at": [pd.Timestamp("2026-01-05 09:00")] * 120,
141
+ "note": ["<img src=x onerror=window.__hit=1>"] * 120})
142
+ page = viewer.open(bf.Briefing(bf.lab.CalendarHeatmap(df, date="run_at", on_click="rows")))
143
+ page.locator('rect[data-date="2026-01-05"]').click()
144
+ panel = page.locator(".bf-cal__panel")
145
+ assert panel.locator("tbody tr").count() == 50
146
+ assert "first 50 of 120 rows" in panel.inner_text()
147
+ assert panel.locator("img").count() == 0 and page.evaluate("() => window.__hit") is None
@@ -1,81 +0,0 @@
1
- """lab.CalendarHeatmap as a reader sees it (#19)."""
2
- from __future__ import annotations
3
-
4
- import pandas as pd
5
- import pytest
6
-
7
- import briefing as bf
8
- from tests.test_browser.conftest import Viewer
9
-
10
-
11
- @pytest.mark.parametrize("theme", ["default", "dark"])
12
- def test_zero_no_data_and_below_target_are_visibly_different(viewer: Viewer, df_pipeline_log: pd.DataFrame,
13
- theme: str) -> None:
14
- formatting = bf.Formatting.dark() if theme == "dark" else bf.Formatting()
15
- block = bf.lab.CalendarHeatmap(df_pipeline_log, date="run_at", value="rows", expected=500)
16
- page = viewer.open(bf.Briefing(block), formatting=formatting)
17
-
18
- def look(day: str) -> dict[str, str]:
19
- style: dict[str, str] = page.locator(f'rect[data-date="{day}"]').evaluate(
20
- "e => { const s = getComputedStyle(e); return {fill: s.fill, stroke: s.stroke, dash: s.strokeDasharray}; }")
21
- return style
22
-
23
- background = page.evaluate("() => getComputedStyle(document.body).backgroundColor")
24
- zero, empty, ok = look("2025-12-04"), look("2026-01-17"), look("2025-11-18")
25
- assert zero["fill"] not in ("none", background) # a zero is a filled grey cell
26
- assert empty["fill"] == "none" # no data is an outline only
27
- assert look("2026-01-03")["stroke"] == "rgb(220, 38, 38)" # a failed run is flagged red
28
- assert ok["stroke"] in ("none", "") # a normal day has no outline
29
-
30
-
31
- def test_the_calendar_fits_its_cells(viewer: Viewer, df_pipeline_log: pd.DataFrame) -> None:
32
- page = viewer.open(bf.Briefing(bf.lab.CalendarHeatmap(df_pipeline_log, date="run_at")))
33
- box = page.locator('rect[data-date="2026-01-05"]').bounding_box()
34
- assert box is not None and box["width"] == pytest.approx(11, abs=0.5)
35
-
36
-
37
- def test_a_tab_switches_between_the_day_and_hour_views(viewer: Viewer, df_pipeline_log: pd.DataFrame) -> None:
38
- page = viewer.open(bf.Briefing(bf.lab.CalendarHeatmap(df_pipeline_log, date="run_at", views=("days", "hours"))))
39
- days, hours = page.locator("svg.bf-cal__days"), page.locator("svg.bf-cal__hours")
40
- assert days.is_visible() and not hours.is_visible()
41
- page.get_by_role("tab", name="Hours").click()
42
- assert hours.is_visible() and not days.is_visible()
43
- assert page.locator("rect.bf-cal__hour").count() == 7 * 24
44
-
45
-
46
- def test_hour_view_labels_are_small(viewer: Viewer, df_pipeline_log: pd.DataFrame) -> None:
47
- page = viewer.open(bf.Briefing(bf.lab.CalendarHeatmap(df_pipeline_log, date="run_at", views=("hours",))))
48
- size = page.locator("svg.bf-cal__hours text").first.evaluate("e => getComputedStyle(e).fontSize")
49
- assert size == "9px"
50
-
51
-
52
- def test_clicking_a_day_lists_its_rows(viewer: Viewer, df_pipeline_log: pd.DataFrame) -> None:
53
- page = viewer.open(bf.Briefing(bf.lab.CalendarHeatmap(df_pipeline_log, date="run_at", on_click="rows")))
54
- panel = page.locator(".bf-cal__panel")
55
- assert not panel.is_visible()
56
- page.locator('rect[data-date="2025-12-07"]').click()
57
- assert panel.is_visible()
58
- assert "2025-12-07" in panel.locator(".bf-cal__panel-title").inner_text()
59
- assert panel.locator("tbody tr").count() == 2
60
- assert panel.locator("thead th").all_inner_texts() == ["run_at", "team", "rows"]
61
- panel.get_by_role("button", name="Close").click()
62
- assert not panel.is_visible()
63
-
64
-
65
- def test_keyboard_opens_the_rows_of_an_hour(viewer: Viewer, df_pipeline_log: pd.DataFrame) -> None:
66
- block = bf.lab.CalendarHeatmap(df_pipeline_log, date="run_at", views=("hours",), on_click="rows")
67
- page = viewer.open(bf.Briefing(block))
68
- page.locator('rect[data-hour="0-6"]').focus()
69
- page.keyboard.press("Enter")
70
- assert "Mon 06:00" in page.locator(".bf-cal__panel-title").inner_text()
71
-
72
-
73
- def test_capped_cells_say_so_and_values_stay_text(viewer: Viewer) -> None:
74
- df = pd.DataFrame({"run_at": [pd.Timestamp("2026-01-05 09:00")] * 120,
75
- "note": ["<img src=x onerror=window.__hit=1>"] * 120})
76
- page = viewer.open(bf.Briefing(bf.lab.CalendarHeatmap(df, date="run_at", on_click="rows")))
77
- page.locator('rect[data-date="2026-01-05"]').click()
78
- panel = page.locator(".bf-cal__panel")
79
- assert panel.locator("tbody tr").count() == 50
80
- assert "first 50 of 120 rows" in panel.inner_text()
81
- assert panel.locator("img").count() == 0 and page.evaluate("() => window.__hit") is None
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes