Skip to content

Commit 4153478

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

27 files changed

Lines changed: 2189 additions & 102 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: 116 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,10 @@ def click_menu_item(locator) -> None:
3838

3939
def open_header_menu(page: Page, trigger_locator, menu_test_id: str):
4040
"""Open a header menu and wait for it to become visible."""
41-
trigger_locator.evaluate("el => el.click()")
41+
expect(trigger_locator).to_be_visible(timeout=5000)
42+
trigger_locator.evaluate(
43+
"el => { el.scrollIntoView({ block: 'center', inline: 'nearest' }); el.click(); }"
44+
)
4245
menu = page.get_by_test_id(menu_test_id)
4346
expect(menu).to_be_visible(timeout=5000)
4447
return menu
@@ -239,6 +242,87 @@ def test_header_menu_show_values_as_pct(page_at_app: Page):
239242
expect(revenue_totals.first).to_contain_text("%", timeout=10000)
240243

241244

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

0 commit comments

Comments
 (0)