Skip to content

Commit 8cb5e79

Browse files
committed
Add adaptive default date grains
1 parent dca8cbf commit 8cb5e79

27 files changed

Lines changed: 2185 additions & 101 deletions

README.md

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,8 @@ Returns a `PivotTableResult` dict containing the current `config` state.
5757
| `values` | `list[str] \| None` | `None` | Column names to aggregate as measures. |
5858
| `synthetic_measures` | `list[dict] \| None` | `None` | Derived measures computed from source-field sums (for example, ratio of sums). See [Synthetic Measures](#synthetic-measures-v1). |
5959
| `aggregation` | `str \| dict[str, str]` | `"sum"` | Aggregation setting for raw value fields. A single string applies to every raw measure; a dict enables per-measure aggregation. See [Aggregation Functions](#aggregation-functions). |
60+
| `auto_date_hierarchy` | `bool` | `True` | Auto-group typed date/datetime fields placed on rows or columns. Default grain is adaptive based on the source data's date range (year for >2 years, quarter for >1 year, month for >2 months, day for shorter ranges). |
61+
| `date_grains` | `dict[str, str \| None] \| None` | `None` | Per-field temporal overrides. Use `"year"`, `"quarter"`, `"month"`, `"week"`, or `"day"`. Use `None` for an explicit `Original` opt-out. |
6062
| `interactive` | `bool` | `True` | Enable end-user config controls. When `False`, the toolbar is hidden and header-menu sort/filter/show-values-as actions are disabled. |
6163

6264
#### Totals and Subtotals
@@ -240,6 +242,10 @@ Display measures as percentages instead of raw numbers.
240242
| % of Grand Total | `"pct_of_total"` | Cell / Grand Total |
241243
| % of Row Total | `"pct_of_row"` | Cell / Row Total |
242244
| % of Column Total | `"pct_of_col"` | Cell / Column Total |
245+
| Diff vs Previous Period | `"diff_from_prev"` | Current bucket minus previous bucket on the active temporal hierarchy |
246+
| % Diff vs Previous Period | `"pct_diff_from_prev"` | Percent change vs previous bucket |
247+
| Diff vs Previous Year | `"diff_from_prev_year"` | Current bucket minus same bucket in the prior year |
248+
| % Diff vs Previous Year | `"pct_diff_from_prev_year"` | Percent change vs same bucket in the prior year |
243249

244250
```python
245251
st_pivot_table(
@@ -254,6 +260,72 @@ st_pivot_table(
254260

255261
Users can also change this interactively via the value header menu (**⋮** icon on a value label header).
256262
Synthetic measures are always rendered as raw derived values (`show_values_as` does not apply to them).
263+
Period-comparison modes appear only when there is an active grouped temporal axis, whether that grouping came from auto hierarchy or an explicit `date_grains` override.
264+
265+
### Date Hierarchy and Time Comparisons
266+
267+
Typed `date` and `datetime` fields are treated as hierarchy-capable dimensions when they are placed on `rows` or `columns`.
268+
269+
- **Adaptive default grain**: with `auto_date_hierarchy=True`, temporal axis fields auto-group based on the date range of the source data (after `source_filters`):
270+
- **>2 years**`year`
271+
- **>1 year**`quarter`
272+
- **>2 months**`month`
273+
- **≤2 months**`day`
274+
- Default drill ladder: `Year -> Quarter -> Month -> Day`.
275+
- Alternate grouping: `Week` is available from the header menu, but it is not part of the default drill path.
276+
- Explicit override precedence: explicit `date_grains[field]` beats interactive state, which beats the adaptive auto default.
277+
- Explicit opt-out: `date_grains[field] = None` preserves the raw/original date values for that field.
278+
279+
```python
280+
# Adaptive date hierarchy: grain chosen from the data's date range
281+
st_pivot_table(
282+
df,
283+
key="date_auto",
284+
rows=["region"],
285+
columns=["order_date"],
286+
values=["Revenue"],
287+
show_values_as={"Revenue": "diff_from_prev"},
288+
)
289+
290+
# Deterministic starting grain from Python
291+
st_pivot_table(
292+
df,
293+
key="date_quarter",
294+
rows=["region"],
295+
columns=["order_date"],
296+
values=["Revenue"],
297+
date_grains={"order_date": "quarter"},
298+
show_values_as={"Revenue": "diff_from_prev_year"},
299+
)
300+
301+
# Disable auto hierarchy globally
302+
st_pivot_table(
303+
df,
304+
key="date_off",
305+
rows=["region"],
306+
columns=["order_date"],
307+
values=["Revenue"],
308+
auto_date_hierarchy=False,
309+
)
310+
311+
# Explicit Original/raw opt-out for one field
312+
st_pivot_table(
313+
df,
314+
key="date_original",
315+
rows=["region"],
316+
columns=["ship_date"],
317+
values=["Revenue"],
318+
date_grains={"ship_date": None},
319+
)
320+
```
321+
322+
Once a temporal field is active on an axis, open its header menu to:
323+
324+
- drill up or down through the default hierarchy,
325+
- switch directly to `Week`,
326+
- choose `Original` to persist a raw-date opt-out for that field.
327+
328+
Grouped buckets export as grouped labels such as `Jan 2024`, `Q1 2024`, or `2024-W03`; they are intentionally not exported as fake raw Excel dates.
257329

258330
### Number Format Patterns
259331

e2e_playwright/e2e_utils.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,9 @@
8787
"test_pivot",
8888
"test_pivot_subtotals",
8989
"test_pivot_cond_fmt",
90+
"test_pivot_date_hierarchy",
91+
"test_pivot_adaptive_year",
92+
"test_pivot_adaptive_month",
9093
"test_pivot_locked",
9194
"test_pivot_locked_groups",
9295
"test_pivot_readonly",

e2e_playwright/pivot_table_interactions_app.py

Lines changed: 76 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717

1818
from __future__ import annotations
1919

20-
import pandas as pd
20+
import pandas as pd # type: ignore[import-untyped]
2121
import streamlit as st
2222

2323
from streamlit_pivot import st_pivot_table
@@ -43,6 +43,28 @@ def _make_drilldown_pagination_data() -> pd.DataFrame:
4343
return pd.DataFrame(rows)
4444

4545

46+
def _make_date_hierarchy_data() -> pd.DataFrame:
47+
return pd.DataFrame(
48+
{
49+
"region": ["US", "US", "US", "US", "EU", "EU", "EU", "EU"],
50+
"order_date": pd.to_datetime(
51+
[
52+
"2024-01-03",
53+
"2024-01-10",
54+
"2024-02-12",
55+
"2025-01-09",
56+
"2024-01-04",
57+
"2024-01-17",
58+
"2024-02-14",
59+
"2025-01-10",
60+
]
61+
),
62+
"revenue": [100, 30, 150, 130, 80, 20, 95, 90],
63+
"profit": [40, 10, 55, 45, 30, 8, 34, 32],
64+
}
65+
)
66+
67+
4668
def render_app(data):
4769
df = data["df"]
4870

@@ -229,6 +251,59 @@ def render_app(data):
229251
on_config_change=noop,
230252
)
231253

254+
st.subheader("Date Hierarchy Pivot")
255+
st_pivot_table(
256+
_make_date_hierarchy_data(),
257+
key="test_pivot_date_hierarchy",
258+
rows=["region"],
259+
columns=["order_date"],
260+
values=["revenue", "profit"],
261+
aggregation="sum",
262+
show_values_as={"revenue": "diff_from_prev"},
263+
interactive=True,
264+
on_config_change=noop,
265+
)
266+
267+
# Adaptive date grain: multi-year dataset -> auto-defaults to "year"
268+
st.subheader("Adaptive Grain (Multi-Year)")
269+
adaptive_year_df = pd.DataFrame(
270+
{
271+
"order_date": pd.to_datetime(
272+
["2019-03-01", "2020-06-15", "2021-09-10", "2023-01-20", "2024-11-05"]
273+
),
274+
"revenue": [100, 200, 300, 400, 500],
275+
}
276+
)
277+
st_pivot_table(
278+
adaptive_year_df,
279+
key="test_pivot_adaptive_year",
280+
rows=["order_date"],
281+
values=["revenue"],
282+
aggregation="sum",
283+
interactive=True,
284+
on_config_change=noop,
285+
)
286+
287+
# Adaptive date grain: 3-month dataset -> auto-defaults to "month"
288+
st.subheader("Adaptive Grain (3 Month)")
289+
adaptive_month_df = pd.DataFrame(
290+
{
291+
"order_date": pd.to_datetime(
292+
["2024-06-01", "2024-06-15", "2024-07-10", "2024-08-05", "2024-08-28"]
293+
),
294+
"revenue": [10, 20, 30, 40, 50],
295+
}
296+
)
297+
st_pivot_table(
298+
adaptive_month_df,
299+
key="test_pivot_adaptive_month",
300+
rows=["order_date"],
301+
values=["revenue"],
302+
aggregation="sum",
303+
interactive=True,
304+
on_config_change=noop,
305+
)
306+
232307
drill_df = _make_drilldown_pagination_data()
233308

234309
st.subheader("Drilldown Pagination (Client)")

e2e_playwright/pivot_table_interactions_test.py

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,87 @@ def test_header_menu_show_values_as_pct(page_at_app: Page):
239239
expect(revenue_totals.first).to_contain_text("%", timeout=10000)
240240

241241

242+
def test_date_hierarchy_uses_adaptive_default_and_enables_comparisons(
243+
page_at_app: Page,
244+
):
245+
page = page_at_app
246+
container = (
247+
page.locator(".st-key-test_pivot_date_hierarchy")
248+
.get_by_test_id("pivot-container")
249+
.first
250+
)
251+
expect(container.get_by_test_id("pivot-table")).to_be_visible(timeout=15000)
252+
253+
expect(container.get_by_text("order_date (Quarter)")).to_be_visible(timeout=5000)
254+
expect(container.get_by_text("Q1 2024")).to_be_visible(timeout=5000)
255+
expect(container.get_by_text("Q1 2025")).to_be_visible(timeout=5000)
256+
257+
menu = open_header_menu(
258+
page,
259+
container.get_by_test_id("header-menu-trigger-revenue").first,
260+
"header-menu-revenue",
261+
)
262+
expect(menu.get_by_test_id("header-display-diff_from_prev")).to_be_visible(
263+
timeout=5000
264+
)
265+
expect(menu.get_by_test_id("header-display-diff_from_prev_year")).to_be_visible(
266+
timeout=5000
267+
)
268+
close_header_menu(page, "header-menu-revenue")
269+
270+
271+
def test_date_hierarchy_supports_drill_week_and_original(page_at_app: Page):
272+
page = page_at_app
273+
container = (
274+
page.locator(".st-key-test_pivot_date_hierarchy")
275+
.get_by_test_id("pivot-container")
276+
.first
277+
)
278+
expect(container.get_by_test_id("pivot-table")).to_be_visible(timeout=15000)
279+
280+
menu = open_header_menu(
281+
page,
282+
container.get_by_test_id("header-menu-trigger-order_date").first,
283+
"header-menu-order_date",
284+
)
285+
grain_select = menu.get_by_test_id("header-date-grain")
286+
expect(grain_select).to_have_value("quarter")
287+
288+
click_menu_item(menu.get_by_test_id("header-date-drill-up"))
289+
close_header_menu(page, "header-menu-order_date")
290+
expect(container.get_by_text("order_date (Year)")).to_be_visible(timeout=5000)
291+
expect(container.get_by_text("2024")).to_be_visible(timeout=5000)
292+
293+
menu = open_header_menu(
294+
page,
295+
container.get_by_test_id("header-menu-trigger-order_date").first,
296+
"header-menu-order_date",
297+
)
298+
grain_select = menu.get_by_test_id("header-date-grain")
299+
grain_select.select_option("week")
300+
close_header_menu(page, "header-menu-order_date")
301+
expect(container.get_by_text("order_date (Week)")).to_be_visible(timeout=5000)
302+
expect(container.get_by_text("2024-W01")).to_be_visible(timeout=5000)
303+
304+
menu = open_header_menu(
305+
page,
306+
container.get_by_test_id("header-menu-trigger-order_date").first,
307+
"header-menu-order_date",
308+
)
309+
grain_select = menu.get_by_test_id("header-date-grain")
310+
grain_select.select_option("")
311+
close_header_menu(page, "header-menu-order_date")
312+
expect(container.get_by_text("order_date")).to_be_visible(timeout=5000)
313+
314+
menu = open_header_menu(
315+
page,
316+
container.get_by_test_id("header-menu-trigger-revenue").first,
317+
"header-menu-revenue",
318+
)
319+
expect(menu.get_by_test_id("header-display-diff_from_prev")).to_have_count(0)
320+
close_header_menu(page, "header-menu-revenue")
321+
322+
242323
def test_drilldown_opens_on_cell_click(page_at_app: Page):
243324
"""Clicking a data cell opens the drilldown panel with a detail table."""
244325
page = page_at_app
@@ -901,3 +982,34 @@ def test_drilldown_hybrid_pagination_navigates(page_at_app: Page):
901982
panel = page.get_by_test_id("drilldown-panel")
902983
expect(panel).to_be_visible(timeout=30000)
903984
expect(panel.locator("text=Page 1 of 2")).to_be_visible(timeout=30000)
985+
986+
987+
# ---------------------------------------------------------------------------
988+
# Adaptive date grain e2e tests
989+
# ---------------------------------------------------------------------------
990+
991+
992+
def test_adaptive_grain_multi_year_defaults_to_year(page_at_app: Page):
993+
"""Multi-year dataset auto-defaults to year-level bucketing."""
994+
page = page_at_app
995+
container = (
996+
page.locator(".st-key-test_pivot_adaptive_year")
997+
.get_by_test_id("pivot-container")
998+
.first
999+
)
1000+
expect(container.get_by_test_id("pivot-table")).to_be_visible(timeout=15000)
1001+
header = container.locator("th").filter(has_text="order_date (Year)")
1002+
expect(header).to_be_visible(timeout=10000)
1003+
1004+
1005+
def test_adaptive_grain_3month_defaults_to_month(page_at_app: Page):
1006+
"""3-month dataset auto-defaults to month-level bucketing."""
1007+
page = page_at_app
1008+
container = (
1009+
page.locator(".st-key-test_pivot_adaptive_month")
1010+
.get_by_test_id("pivot-container")
1011+
.first
1012+
)
1013+
expect(container.get_by_test_id("pivot-table")).to_be_visible(timeout=15000)
1014+
header = container.locator("th").filter(has_text="order_date (Month)")
1015+
expect(header).to_be_visible(timeout=10000)

0 commit comments

Comments
 (0)