Merge pull request #322 from fiddlerwoaroof/feat/8jk6.3-date-hierarchy

feat(import): a date column imports as one hierarchical category (improvise-8jk6.3)
This commit is contained in:
Ed Langley
2026-10-02 22:32:35 -07:00
committed by GitHub
12 changed files with 919 additions and 20 deletions
+53
View File
@@ -613,6 +613,59 @@ different row order on screen, and a different detected date format, since
varied. The `HashSet` in `analyze_records` is there to deduplicate and **never
to iterate**.
### A date column imports as ONE hierarchical category (improvise-8jk6.3)
The user: "I think dates should usually be one category and we can use
formulas to crosstab." So the default `DateShape::Hierarchy` builds one
category named after the column, where the old import built up to three flat
ones beside it (`Date_Year`, `Date_Quarter`, `Date_Month`):
- **Its items are the finest chosen component**, spelled so they stay unique
across years: `2024-01`, `2024-Q1`, `2024`, which are
`extract_date_component`'s existing spellings. A month is never `Jan`.
- **The coarser chosen components are its groups**, coarsest first
(`DateComponent::COARSE_TO_FINE`): a month sits in its quarter, and the
quarter's `>` line names its year. A level left unchosen is skipped rather
than drawn empty, so Year + Month groups months straight into years.
- **It opens folded**, one row per year, with nothing to set, because every
group starts closed (`context/code/core.md` §19).
- **It costs one of the category slots**, not up to four, and
`proposed_category_count` counts it that way.
- **The raw dates are gone from that category.** The day is not a component, so
two records of one month land on one cell and the collision step asks, as it
does for any other collision. `Split` keeps the day.
- **A value the detected format cannot read keeps its raw text** as an
ungrouped item, with a warning naming the column and the row count, rather
than being dropped. The warning is separate from the split shape's, because
that one reports an absent coordinate and this one a coordinate that is there
and ungrouped. **Unless its spelling is one the hierarchy generates**
(improvise-d0n6): a raw `2024-01` or `2024` is the same name as the generated
month or year, so it would join the grouped item and the warning would be
false. The build **refuses**, naming the column and the spellings and pointing
at the split shape. That follows the collision rule below: where two records
would silently be one thing, the user is asked and nothing is defaulted. A
fallback to the split shape was rejected as a silent change of the user's
choice, and a renamed raw item as altering the data. The check is
`Plan::spelling_clash`.
- **Item order is first-seen, not chronological.** Order is a correctness
property here (gotcha 18), and a group need not be contiguous, so a CSV that
is not in date order still nests correctly. It is just drawn in the order
it was read.
`DateShape::Split` is the old behaviour, unchanged, and stays a choice. `s` on
the wizard's date step toggles the field under the cursor, and the header line
for each field says which it will get ("one category: Year > Quarter > Month"
or "split: Date, Date_Year, …"). It is worded once, in
`project.rs::date_shape_sentence`. Headless, `--split-date <field>` does the
same, and `--extract` keeps its meaning: which components to build.
**Crosstabs by formula are not available yet.** A formula can name a month
(`SUM(Amount WHERE Date = "2024-01")`, `Date IS "2024-01"`), but a `WHERE` or
`IS` naming a GROUP matches no record: both compare the coordinate to the name
exactly. `SUM(Amount WHERE Date = "2024")` is an empty sum, and `Date IS "2024"`
is 0 on every month of 2024. That was measured on this branch and reported for
filing, not built here.
### The ambiguous-date heuristic reads American-first, deliberately
`01/02/2025` parses under both `%m/%d/%Y` and `%d/%m/%Y` and nothing in the data
+97
View File
@@ -1685,6 +1685,44 @@ closed, the one its `collapsed:` line names and the three it does not, and the
line is written back. Rival: the old open default; this test fails with the
other eleven in that rival's list.
### a_date_column_imports_as_one_category_of_months_in_quarters_in_years
improvise-8jk6.3, the user's "dates should usually be one category". The
fixture has two days of one month, so that month is one summed cell, and
January of the next year, so a month must stay unique across years. Rivals, each
run against the `date_hierarchy` module, and each caught here:
- The split as the default shape.
- Levels taken finest first, so the year hangs under the month.
- Groups registered without their parents.
- The raw dates still added beside the months.
- A hierarchy counted as `1 + components` slots.
### a_level_left_unchosen_is_skipped_not_emptied
Year + Month nests months straight into years, and Quarter alone is a flat
category of quarters. Caught the finest-first rival and the raw-dates rival.
### an_imported_date_hierarchy_opens_folded_to_its_years
The import sets nothing: the hierarchy opens folded, one row per year, because
every group starts closed (improvise-felo.6). The 2024 row is the fold of its
months, 111. Caught three of the rivals above.
### an_unparsed_date_keeps_its_value_and_is_warned_about
A format that reads none of the values keeps each raw value as an ungrouped
item, and the build says so. Its first version passed on the base commit: the
shape was ignored there and the SPLIT's warning happened to contain the same
words. It now also asserts there is no `Date_Year`. Rival: the unparsed value
not counted; this test fails.
### a_headless_date_import_splits_when_told_to
`--split-date` is the wizard's `s`, headless. It failed first as an unknown
argument. Rival: the flag parsed and ignored; this test fails, and
`a_headless_date_import_is_one_hierarchical_category` does not, which is the
point of having both.
### every_fold_the_api_can_build_reads_back_from_the_file_it_writes
ChatGPT Codex, P2 on #317 at `e09ec752`: `GroupFold::Expanded(usize)` and
@@ -1939,6 +1977,45 @@ predates this PR (improvise-ych), but the sparse-record rule now rests on it:
without it the value becomes an item named `" "`. Rival F4 (the reviewer's
fault) fails it.
### a_raw_date_spelled_like_a_generated_item_is_refused
improvise-d0n6 (Codex P2 on PR #322). A `%Y-%m-%d` column imported Year > Month
with `2024-01-15` and a raw `2024-01`. Before the refusal the build succeeded,
put both records on the grouped item `2024-01` (summing them) and warned that
the raw value was "kept ungrouped", which was false. Fault injected: removing
the `spelling_clash` call makes this test fail with `unwrap_err()` on an `Ok`.
### a_raw_date_spelled_like_a_generated_group_is_refused
improvise-d0n6. The same with a raw `2024`, a generated group. Removing the
`spelling_clash` call fails it the same way. The control,
`a_raw_date_spelled_like_nothing_generated_is_kept_ungrouped`, passes either
way, so a blanket refusal of every unparsed value fails it.
### the_date_step_will_not_advance_over_a_spelling_clash
improvise-d0n6 (Copilot on PR #322, and the adversarial review's B1). The
spelling-clash refusal sat only in the build, so a wizard with a raw `2024` in
a `%Y-%m-%d` column passed ConfigureDates and failed on NameModel, three steps
from the `s` that fixes it. Before the check joined `validation_error` the
test failed with the step already `DefineFormulas`; removing that call again
reproduces it. It also toggles to the split shape and requires the step to
advance, so a check that blocks forever fails.
### split_date_naming_an_unknown_or_non_date_field_is_refused
improvise-tpm8 class. `--split-date Nope` and `--split-date Revenue` (a
measure) exited 0 and did nothing. Both now exit 1 naming the flag and the
field; removing the check in `run_headless_import` fails both rows.
### an_axis_naming_a_category_the_import_did_not_build_is_refused
improvise-8jk6.3 review. `--extract Date:Month --axis Date_Month:column` used
to build one `Date` category and drop the `--axis` for the vanished
`Date_Month`, exit 0. It now exits 1 naming the category and `--split-date`.
This also refuses any `--axis` naming an absent category, which was silently
ignored before this PR as well.
### a_folder_of_only_header_only_files_keeps_no_record_and_refuses
improvise-8jk6.6 meeting PR #314's rule that a header-only file is still a
@@ -1949,3 +2026,23 @@ items and empty views: the category check alone cannot refuse. The `setup`
assertion proves that seeded proposal exists, so the test is not passing only
because there was no category. **R7**, the check removed from the build, fails
it. The user confirmed the refusal on PR #321: "Refuse (as #321 does)".
### an_absent_axis_category_that_is_no_date_part_names_what_was_built
improvise-8jk6.3 review (B2). The README's own recipe passed
`--axis Measure:column`, a silent no-op because the category is `_Measure`; the
first refusal blamed dates for it. The test uses a non-date absent name and
asserts the message lists the built categories and carries no date hint.
Fault injected: making the hint unconditional fails it.
### the_readmes_import_recipe_runs
improvise-8jk6.3 review (B2). Runs the README's recipe as printed. Fault
injected: restoring `--axis Measure:column` in the README fails it with the
refusal.
### split_date_on_a_time_field_with_no_date_format_is_refused
improvise-8jk6.3 review. `--time Revenue --split-date Revenue` made the field a
time category with no date format and the split was a silent no-op. Without
the `date_format.is_some()` condition the import exits 0 and this fails.