From 9a365cf6581d4bbb527ebab8507625c1044af25d Mon Sep 17 00:00:00 2001 From: Julien Fontanet Date: Fri, 21 Aug 2026 15:35:31 +0200 Subject: [PATCH 1/4] fix(core): missing-value handling in groupValue bucketers + log-scale bucketing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit bucketNumericRange/bucketDatePart silently mishandled a missing (null/ undefined) value instead of routing it to its own group: - bucketNumericRange: Number(null) === 0, so a missing value merged into the same bucket as a real, confirmed 0. - bucketDatePart: String(null) === "null" (not ''), which fails to parse and surfaced as the literal group header text "null". bucketNumericRange also leaked the literal text "NaN" for any non-numeric input, since NaN isn't nullish and stringifies as-is. Both now return null for a missing/non-numeric value, which groupData's existing multiValues stringifies to '' — the same key an *unbucketed* missing scalar already uses, so no new sentinel is introduced. formatNumericRange/formatDatePart gained a 3rd `missingLabel = '(none)'` param rendered for that group. Also adds two related, previously-requested pieces (GitHub issue #18): - bucketLogRange/formatLogRange: log-scale groupValue/groupFormat for a right-skewed numeric column (review counts, hours played, file sizes) spanning several orders of magnitude, where a single linear bucketNumericRange step is either too coarse for the long tail or too fine for the low end. LogRangeOptions { base, divisions, min } generalizes to plain order-of-magnitude (default), log2/octaves (base: 2), a half-decade "1-3-10" grid (divisions: [1, 3]), or any other per-base-cycle split. A value below `min` (default 1, since log is undefined at/below 0 regardless) collapses into a distinct "< min" bucket, kept separate from the null "missing" key; pass min: 0 to opt out of that collapse for positive values. - numericRangeGroup/datePartGroup/logRangeGroup: each bundles a bucketer with its matching formatter into one { groupValue, groupFormat } pair from a single set of arguments, spreadable into a column def — removes the config-divergence risk of passing the same step/unit/part/options to both halves separately. Fixes #18 Co-Authored-By: Claude Sonnet 5 --- packages/core/src/__tests__/logic.test.ts | 142 +++++++++++++++- packages/core/src/logic.ts | 190 +++++++++++++++++++++- 2 files changed, 323 insertions(+), 9 deletions(-) diff --git a/packages/core/src/__tests__/logic.test.ts b/packages/core/src/__tests__/logic.test.ts index c890874..72fb0bd 100644 --- a/packages/core/src/__tests__/logic.test.ts +++ b/packages/core/src/__tests__/logic.test.ts @@ -59,8 +59,13 @@ import { computeVirtualRange, bucketNumericRange, formatNumericRange, + numericRangeGroup, bucketDatePart, formatDatePart, + datePartGroup, + bucketLogRange, + formatLogRange, + logRangeGroup, compareMissingLast, } from '../logic' @@ -890,8 +895,13 @@ describe('bucketNumericRange', () => { expect(bucket(0)).toBe(0) }) - it('returns NaN for a non-numeric value', () => { - expect(bucketNumericRange(10)('abc')).toBeNaN() + it('returns null for a non-numeric value', () => { + expect(bucketNumericRange(10)('abc')).toBeNull() + }) + + it('returns null for a missing value, instead of coercing it to 0', () => { + expect(bucketNumericRange(10)(null)).toBeNull() + expect(bucketNumericRange(10)(undefined)).toBeNull() }) }) @@ -904,6 +914,21 @@ describe('formatNumericRange', () => { it('returns the raw key unchanged when it is not numeric', () => { expect(formatNumericRange(10)('abc')).toBe('abc') }) + + it('renders the missing-value bucket key as missingLabel', () => { + expect(formatNumericRange(10)('')).toBe('(none)') + expect(formatNumericRange(10, '', 'Unknown')('')).toBe('Unknown') + }) +}) + +describe('numericRangeGroup', () => { + it('returns a matched groupValue/groupFormat pair', () => { + const { groupValue, groupFormat } = numericRangeGroup(10, '%') + expect(groupValue(47)).toBe(40) + expect(groupFormat(String(groupValue(47)))).toBe('40–50%') + expect(groupValue(null)).toBeNull() + expect(groupFormat('')).toBe('(none)') + }) }) // ─── bucketDatePart / formatDatePart ────────────────────────────────────────── @@ -924,6 +949,11 @@ describe('bucketDatePart', () => { it('returns the raw value unchanged when it does not parse as a date', () => { expect(bucketDatePart('month')('not-a-date')).toBe('not-a-date') }) + + it('returns null for a missing value, instead of the literal text "null"', () => { + expect(bucketDatePart('month')(null)).toBeNull() + expect(bucketDatePart('month')(undefined)).toBeNull() + }) }) describe('formatDatePart', () => { @@ -940,6 +970,114 @@ describe('formatDatePart', () => { it('returns the raw key unchanged when it does not parse as a date', () => { expect(formatDatePart('month')('not-a-date')).toBe('not-a-date') }) + + it('renders the missing-value bucket key as missingLabel', () => { + expect(formatDatePart('month')('')).toBe('(none)') + expect(formatDatePart('month', 'Unknown')('')).toBe('Unknown') + }) +}) + +describe('datePartGroup', () => { + it('returns a matched groupValue/groupFormat pair', () => { + const { groupValue, groupFormat } = datePartGroup('month') + expect(groupValue('2024-05-14')).toBe('2024-05-01') + expect(groupFormat(groupValue('2024-05-14') as string)).toBe( + new Date('2024-05-01').toLocaleDateString(undefined, { month: 'long', year: 'numeric' }), + ) + expect(groupValue(null)).toBeNull() + expect(groupFormat('')).toBe('(none)') + }) +}) + +// ─── bucketLogRange / formatLogRange ────────────────────────────────────────── + +describe('bucketLogRange', () => { + it('buckets by plain order of magnitude with the default divisions ([1])', () => { + const bucket = bucketLogRange() + expect(bucket(1)).toBe(1) + expect(bucket(9)).toBe(1) + expect(bucket(10)).toBe(10) + expect(bucket(99)).toBe(10) + expect(bucket(100)).toBe(100) + }) + + it('supports a half-decade "1-3-10" grid via divisions', () => { + const bucket = bucketLogRange({ divisions: [1, 3] }) + expect(bucket(1)).toBe(1) + expect(bucket(2.9)).toBe(1) + expect(bucket(3)).toBe(3) + expect(bucket(9)).toBe(3) + expect(bucket(10)).toBe(10) + expect(bucket(29)).toBe(10) + expect(bucket(30)).toBe(30) + }) + + it('supports a different base, e.g. octaves (base 2)', () => { + const bucket = bucketLogRange({ base: 2, min: 1 }) + expect(bucket(1)).toBe(1) + expect(bucket(3)).toBe(2) + expect(bucket(4)).toBe(4) + expect(bucket(1023)).toBe(512) + expect(bucket(1024)).toBe(1024) + }) + + it('collapses values below min into a distinct sentinel bucket, not the missing-value one', () => { + const bucket = bucketLogRange({ min: 1 }) + expect(bucket(0.5)).toBe(-Infinity) + expect(bucket(0)).toBe(-Infinity) + expect(bucket(-5)).toBe(-Infinity) + expect(bucket(null)).toBeNull() + expect(bucket(0.5)).not.toBe(bucket(null)) + }) + + it('min: 0 opts out of the collapse bucket for any positive value, extending the grid down toward zero', () => { + const bucket = bucketLogRange({ min: 0 }) + expect(bucket(0.5)).toBe(0.1) + expect(bucket(0.05)).toBe(0.01) + // zero/negative still have no log-scale bucket to extend into, regardless of min + expect(bucket(0)).toBe(-Infinity) + expect(bucket(-5)).toBe(-Infinity) + }) + + it('returns null for a missing or non-numeric value', () => { + const bucket = bucketLogRange() + expect(bucket(null)).toBeNull() + expect(bucket(undefined)).toBeNull() + expect(bucket('abc')).toBeNull() + }) +}) + +describe('formatLogRange', () => { + it('formats a bucket key as ""', () => { + const format = formatLogRange({ divisions: [1, 3] }) + expect(format('1')).toBe('1–3') + expect(format('3')).toBe('3–10') + expect(format('30')).toBe('30–100') + }) + + it('applies k/M magnitude suffixes', () => { + const format = formatLogRange() + expect(format('1000')).toBe('1k–10k') + expect(format('1000000')).toBe('1M–10M') + }) + + it('formats the below-min bucket as "<"', () => { + expect(formatLogRange({ min: 1 }, 'h')('-Infinity')).toBe('<1h') + }) + + it('renders the missing-value bucket key as missingLabel', () => { + expect(formatLogRange()('')).toBe('(none)') + }) +}) + +describe('logRangeGroup', () => { + it('returns a matched groupValue/groupFormat pair', () => { + const { groupValue, groupFormat } = logRangeGroup({ divisions: [1, 3] }, 'h') + expect(groupValue(7)).toBe(3) + expect(groupFormat(String(groupValue(7)))).toBe('3–10h') + expect(groupValue(null)).toBeNull() + expect(groupFormat('')).toBe('(none)') + }) }) // ─── getVisibleRows ────────────────────────────────────────────────────────── diff --git a/packages/core/src/logic.ts b/packages/core/src/logic.ts index 0eee2dc..6cca033 100644 --- a/packages/core/src/logic.ts +++ b/packages/core/src/logic.ts @@ -363,22 +363,55 @@ export function sortWithinGroups( * per distinct value. The returned number keeps `sortWithinGroups`' existing numeric comparison * correct with no separate sort key needed; pair with `formatNumericRange` to render the range * itself (e.g. `"40–50"`) instead of just its lower bound in the group header. + * + * A missing (`null`/`undefined`) or non-numeric value returns `null` rather than coercing it — + * `Number(null) === 0` would otherwise silently merge "no value" into the same bucket as a real, + * confirmed `0`, and `Number(undefined)`/`Number('abc')` being `NaN` would otherwise flow through + * to a group key that stringifies to the literal text `"NaN"` (see `groupData`'s `multiValues`). + * `null` stringifies to `''` the same way an *unbucketed* missing value already does, so it lands + * in the same "no value" group a plain (non-bucketed) numeric column's missing rows already get. */ -export function bucketNumericRange(step: number): (value: unknown) => number { +export function bucketNumericRange(step: number): (value: unknown) => number | null { return (value: unknown) => { + if (value == null) return null const n = Number(value) - return isNaN(n) ? NaN : Math.floor(n / step) * step + return isNaN(n) ? null : Math.floor(n / step) * step } } -/** Formats a `bucketNumericRange(step)` key as `""`, e.g. `"40–50%"`. */ -export function formatNumericRange(step: number, unit = ''): (keyPart: string) => string { +/** + * Formats a `bucketNumericRange(step)` key as `""`, e.g. `"40–50%"`. + * `missingLabel` renders the "no value" group (see `bucketNumericRange`); a keyPart that didn't + * come from `bucketNumericRange` and isn't a number is returned unchanged rather than replaced, + * on the assumption it's meaningful raw text from some other source. + */ +export function formatNumericRange( + step: number, + unit = '', + missingLabel = '(none)', +): (keyPart: string) => string { return (keyPart: string) => { + if (keyPart === '') return missingLabel const n = Number(keyPart) return isNaN(n) ? keyPart : `${n}–${n + step}${unit}` } } +/** `bucketNumericRange`/`formatNumericRange` as one matched pair, so `step`/`unit`/`missingLabel` + * are given once instead of twice — passing them to only one half is a config-divergence bug + * that fails silently (a group header disagreeing with its own bucket's real boundaries). Spread + * directly into a column def: `{ key: 'price', ...numericRangeGroup(10, '%') }`. */ +export function numericRangeGroup( + step: number, + unit = '', + missingLabel = '(none)', +): { groupValue: (value: unknown) => number | null; groupFormat: (keyPart: string) => string } { + return { + groupValue: bucketNumericRange(step), + groupFormat: formatNumericRange(step, unit, missingLabel), + } +} + /** Coarser granularity `bucketDatePart`/`formatDatePart` group a `type: 'date'` column by. */ export type DatePart = 'year' | 'month' | 'day' @@ -389,12 +422,19 @@ export type DatePart = 'year' | 'month' | 'day' * so `sortWithinGroups`' existing chronological comparison stays correct with no separate sort * key needed. Pair with `formatDatePart` to render a human label (e.g. `"May 2024"`) instead of * the raw ISO bucket key in the group header. + * + * A missing (`null`/`undefined`) value returns `null` rather than falling through to + * `String(value)` — `String(null) === "null"`, a 4-character string that fails to parse and + * would otherwise surface as the literal group header text "null". A genuinely invalid *but + * present* value (e.g. `"not-a-date"`) still returns it unchanged — that case has real raw text + * worth keeping, unlike a missing value. */ export function bucketDatePart( part: DatePart, parseDate: (value: string) => number = defaultParseDate, -): (value: unknown) => string { +): (value: unknown) => string | null { return (value: unknown) => { + if (value == null) return null const t = parseDate(String(value)) if (isNaN(t)) return String(value) const { y, m, day: dayPart } = datePartsOf(new Date(t)) @@ -403,9 +443,14 @@ export function bucketDatePart( } } -/** Formats a `bucketDatePart(part)` ISO key for display, e.g. `"2024-05-01"` -> `"May 2024"` for `'month'`. */ -export function formatDatePart(part: DatePart): (keyPart: string) => string { +/** Formats a `bucketDatePart(part)` ISO key for display, e.g. `"2024-05-01"` -> `"May 2024"` for + * `'month'`. `missingLabel` renders the "no value" group (see `bucketDatePart`). */ +export function formatDatePart( + part: DatePart, + missingLabel = '(none)', +): (keyPart: string) => string { return (keyPart: string) => { + if (keyPart === '') return missingLabel const d = new Date(keyPart) if (isNaN(d.getTime())) return keyPart if (part === 'year') return String(d.getFullYear()) @@ -414,6 +459,137 @@ export function formatDatePart(part: DatePart): (keyPart: string) => string { } } +/** `bucketDatePart`/`formatDatePart` as one matched pair — see `numericRangeGroup`'s own doc for + * why (`part`/`parseDate` given once instead of twice). Spread directly into a column def: + * `{ key: 'releaseDate', ...datePartGroup('month') }`. */ +export function datePartGroup( + part: DatePart, + parseDate: (value: string) => number = defaultParseDate, + missingLabel = '(none)', +): { groupValue: (value: unknown) => string | null; groupFormat: (keyPart: string) => string } { + return { + groupValue: bucketDatePart(part, parseDate), + groupFormat: formatDatePart(part, missingLabel), + } +} + +/** Options shared by `bucketLogRange`/`formatLogRange`/`logRangeGroup`. */ +export interface LogRangeOptions { + /** Multiplier per exponent step: `10` for decades (default), `2` for octaves/binary doublings. */ + base?: number + /** + * Bucket starts within one `base` cycle, as multipliers of `base ** exponent` — e.g. `[1, 3]` + * splits each decade into a "1–3" and a "3–10" bucket (a "1-3-10" half-decade grid); `[1, 2, 5]` + * gives the common "1-2-5" grid. Must include `1`. Defaults to `[1]`: one bucket per power of + * `base` — plain order-of-magnitude (or per-octave, with `base: 2`). + */ + divisions?: number[] + /** + * Values below this collapse into a single low bucket instead of extending the log grid down + * toward zero — the near-zero tail of a right-skewed column (mostly-unplayed games, mostly-empty + * carts) is rarely worth splitting further, and `log` is undefined at/below `0` regardless. + * Default `1`. Pass `min: 0` to opt out of the collapse bucket and let the grid extend all the + * way down to (but not including) zero — `0` and negative values still always fall into the + * below-threshold bucket no matter what `min` is set to, since they have no log-scale bucket to + * extend into. + */ + min?: number +} + +const LOG_RANGE_EPSILON = 1e-9 +/** Sentinel bucket key for "below `min`" — sorts before every real bucket (all `>= min > 0`) and + * is distinguishable from the `null` "missing" key used by every other bucketer in this file. */ +const BELOW_LOG_MIN = -Infinity + +function sortedDivisions(divisions: number[] | undefined): number[] { + return [...(divisions ?? [1])].sort((a, b) => a - b) +} + +/** The bucket-start value just after `start` in the same `base`/`divisions` grid `bucketLogRange` + * produced `start` from — used by `formatLogRange` to render a bucket's upper bound without + * `bucketLogRange` needing to return a `[start, end]` pair (every other bucketer in this file + * returns a single scalar key too, so `groupData`/`sortWithinGroups` only ever handle one shape). */ +function logRangeBoundaryAfter(start: number, base: number, divisions: number[]): number { + const exp = Math.floor(Math.log(start) / Math.log(base) + LOG_RANGE_EPSILON) + const normalized = start / base ** exp + const idx = divisions.findIndex( + (d) => Math.abs(d - normalized) < LOG_RANGE_EPSILON * Math.max(normalized, 1), + ) + return idx === divisions.length - 1 + ? divisions[0] * base ** (exp + 1) + : divisions[idx + 1] * base ** exp +} + +function formatLogMagnitude(n: number): string { + const trim = (v: number) => Number(v.toPrecision(12)).toString() + if (n >= 1e6) return `${trim(n / 1e6)}M` + if (n >= 1e3) return `${trim(n / 1e3)}k` + return trim(n) +} + +/** + * Ready-made `groupValue` bucketing function for a `type: 'number'` column on a logarithmic + * scale — for right-skewed columns (review counts, hours played, file sizes) spanning several + * orders of magnitude, where any single linear `bucketNumericRange` step is either too coarse + * for the long tail or too fine for the low end. See `LogRangeOptions` for `base`/`divisions`/ + * `min`. Missing/non-numeric values return `null`, same convention as `bucketNumericRange`; a + * value below `min` (or `<= 0`, always unbucketable on a log scale) returns a distinct sentinel + * so it groups apart from "no value" rather than merging with it. Pair with `formatLogRange` + * (same options) to render a bucket's range in the group header, or use `logRangeGroup` for both. + */ +export function bucketLogRange(options: LogRangeOptions = {}): (value: unknown) => number | null { + const { base = 10, min = 1 } = options + const divisions = sortedDivisions(options.divisions) + return (value: unknown) => { + if (value == null) return null + const n = Number(value) + if (isNaN(n)) return null + if (n <= 0 || n < min) return BELOW_LOG_MIN + let exp = Math.floor(Math.log(n) / Math.log(base) + LOG_RANGE_EPSILON) + for (;;) { + for (let i = divisions.length - 1; i >= 0; i--) { + const start = base ** exp * divisions[i] + if (start <= n * (1 + LOG_RANGE_EPSILON)) return start + } + exp-- + } + } +} + +/** Formats a `bucketLogRange(options)` key as `""` (e.g. `"3–10"`, + * `"300k–1M"`), or `"<"` for the below-`min` bucket. Same `options` as the bucketer; + * `missingLabel` renders the "no value" group. */ +export function formatLogRange( + options: LogRangeOptions = {}, + unit = '', + missingLabel = '(none)', +): (keyPart: string) => string { + const { base = 10, min = 1 } = options + const divisions = sortedDivisions(options.divisions) + return (keyPart: string) => { + if (keyPart === '') return missingLabel + const n = Number(keyPart) + if (isNaN(n)) return keyPart + if (n === BELOW_LOG_MIN) return `<${formatLogMagnitude(min)}${unit}` + const end = logRangeBoundaryAfter(n, base, divisions) + return `${formatLogMagnitude(n)}–${formatLogMagnitude(end)}${unit}` + } +} + +/** `bucketLogRange`/`formatLogRange` as one matched pair — see `numericRangeGroup`'s own doc for + * why (`options`/`unit`/`missingLabel` given once instead of twice). Spread directly into a + * column def: `{ key: 'hoursPlayed', ...logRangeGroup({ divisions: [1, 3] }) }`. */ +export function logRangeGroup( + options: LogRangeOptions = {}, + unit = '', + missingLabel = '(none)', +): { groupValue: (value: unknown) => number | null; groupFormat: (keyPart: string) => string } { + return { + groupValue: bucketLogRange(options), + groupFormat: formatLogRange(options, unit, missingLabel), + } +} + /** * A single keyboard-navigable target in the table body: a group header row, or a data row. * `groupKey` on a row item is the key of its enclosing group (`null` when ungrouped), set by From cac45342f85888be81c0313b4ada5e8ddd7abf9b Mon Sep 17 00:00:00 2001 From: Julien Fontanet Date: Fri, 21 Aug 2026 15:36:05 +0200 Subject: [PATCH 2/4] feat(react,vue,solid,vanilla): re-export new groupValue bucketing helpers Re-export bucketLogRange/formatLogRange, numericRangeGroup/datePartGroup/ logRangeGroup, and LogRangeOptions from every adapter package, alongside the existing bucketNumericRange/formatNumericRange/bucketDatePart/ formatDatePart re-exports. Co-Authored-By: Claude Sonnet 5 --- packages/react/src/index.ts | 6 ++++++ packages/solid/src/index.ts | 25 +++++++++++++++++++++++-- packages/vanilla/src/index.tsx | 19 +++++++++++++++++-- packages/vue/src/index.ts | 6 ++++++ 4 files changed, 52 insertions(+), 4 deletions(-) diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 1152f09..f8e90b1 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -19,9 +19,15 @@ export * from '@vates/data-table-core/locales' export { bucketNumericRange, formatNumericRange, + numericRangeGroup, bucketDatePart, formatDatePart, + datePartGroup, + bucketLogRange, + formatLogRange, + logRangeGroup, } from '@vates/data-table-core' +export type { LogRangeOptions } from '@vates/data-table-core' // Ready-made compare for pinning a value (missing data, by default) last regardless of sort // direction — see `ColumnDefBase.compare` in the docs. export { compareMissingLast } from '@vates/data-table-core' diff --git a/packages/solid/src/index.ts b/packages/solid/src/index.ts index 6a8ef41..03a7500 100644 --- a/packages/solid/src/index.ts +++ b/packages/solid/src/index.ts @@ -1,17 +1,38 @@ import { bucketNumericRange, formatNumericRange, + numericRangeGroup, bucketDatePart, formatDatePart, + datePartGroup, + bucketLogRange, + formatLogRange, + logRangeGroup, compareMissingLast, } from '@vates/data-table-core' export type { ColumnDef } from './types' -export type { DataTableLabels, TableViewState, DatePart, GetRowId } from '@vates/data-table-core' +export type { + DataTableLabels, + TableViewState, + DatePart, + GetRowId, + LogRangeOptions, +} from '@vates/data-table-core' export * from '@vates/data-table-core/locales' // Ready-made groupValue/groupFormat pairs for bucketing a continuous/high-cardinality column // (percentages, timestamps) into coarser groups — see `ColumnDefBase.groupValue` in the docs. -export { bucketNumericRange, formatNumericRange, bucketDatePart, formatDatePart } +export { + bucketNumericRange, + formatNumericRange, + numericRangeGroup, + bucketDatePart, + formatDatePart, + datePartGroup, + bucketLogRange, + formatLogRange, + logRangeGroup, +} // Ready-made compare for pinning a value (missing data, by default) last regardless of sort // direction — see `ColumnDefBase.compare` in the docs. export { compareMissingLast } diff --git a/packages/vanilla/src/index.tsx b/packages/vanilla/src/index.tsx index e5aa905..b10dcc6 100644 --- a/packages/vanilla/src/index.tsx +++ b/packages/vanilla/src/index.tsx @@ -4,8 +4,13 @@ import { createTableState, DataTableView } from '@vates/data-table-solid' import { bucketNumericRange, formatNumericRange, + numericRangeGroup, bucketDatePart, formatDatePart, + datePartGroup, + bucketLogRange, + formatLogRange, + logRangeGroup, compareMissingLast, } from '@vates/data-table-core' import type { GetRowId } from '@vates/data-table-core' @@ -23,11 +28,21 @@ export type { export * from '@vates/data-table-core/locales' // Ready-made groupValue/groupFormat pairs for bucketing a continuous/high-cardinality column // (percentages, timestamps) into coarser groups — see `ColumnDefBase.groupValue` in the docs. -export { bucketNumericRange, formatNumericRange, bucketDatePart, formatDatePart } +export { + bucketNumericRange, + formatNumericRange, + numericRangeGroup, + bucketDatePart, + formatDatePart, + datePartGroup, + bucketLogRange, + formatLogRange, + logRangeGroup, +} // Ready-made compare for pinning a value (missing data, by default) last regardless of sort // direction — see `ColumnDefBase.compare` in the docs. export { compareMissingLast } -export type { DatePart } from '@vates/data-table-core' +export type { DatePart, LogRangeOptions } from '@vates/data-table-core' // --- Factory --- diff --git a/packages/vue/src/index.ts b/packages/vue/src/index.ts index a949133..8f01e53 100644 --- a/packages/vue/src/index.ts +++ b/packages/vue/src/index.ts @@ -25,9 +25,15 @@ export * from '@vates/data-table-core/locales' export { bucketNumericRange, formatNumericRange, + numericRangeGroup, bucketDatePart, formatDatePart, + datePartGroup, + bucketLogRange, + formatLogRange, + logRangeGroup, } from '@vates/data-table-core' +export type { LogRangeOptions } from '@vates/data-table-core' // Ready-made compare for pinning a value (missing data, by default) last regardless of sort // direction — see `ColumnDefBase.compare` in the docs. export { compareMissingLast } from '@vates/data-table-core' From 78c759e89d4c31bb13a6ffa0e061a0a47a423b61 Mon Sep 17 00:00:00 2001 From: Julien Fontanet Date: Fri, 21 Aug 2026 15:36:38 +0200 Subject: [PATCH 3/4] docs: document new groupValue bucketing helpers (issue #18) Cover the missing-value fix and bucketLogRange/numericRangeGroup/ datePartGroup/logRangeGroup in CLAUDE.md (new "Ready-made groupValue bucketers" section), docs/grouped-columns.md, docs/solid-package.md's index.ts re-export list, every adapter README's grouping section, and core's README API reference. CHANGELOG entries added under [Unreleased]. Co-Authored-By: Claude Sonnet 5 --- CHANGELOG.md | 9 +++++++++ CLAUDE.md | 10 +++++++++- docs/grouped-columns.md | 2 +- docs/solid-package.md | 2 +- packages/core/README.md | 14 ++++++++++---- packages/react/README.md | 28 +++++++++++++++++++++++++++- packages/vanilla/README.md | 28 +++++++++++++++++++++++++++- packages/vue/README.md | 28 +++++++++++++++++++++++++++- 8 files changed, 111 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b52be1b..59e4a96 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,15 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm ## [Unreleased] +### Added + +- `bucketLogRange(options?)`/`formatLogRange(options?, unit?, missingLabel?)` (core, re-exported from every adapter) — a ready-made `groupValue`/`groupFormat` pair for bucketing a `type: 'number'` column on a logarithmic scale, for a right-skewed column spanning several orders of magnitude (review counts, hours played, file sizes) where any single linear `bucketNumericRange` step is either too coarse for the long tail or too fine for the low end. `LogRangeOptions` (`{ base?, divisions?, min? }`) generalizes to a plain order-of-magnitude scale (`base: 10`, default `divisions: [1]`), octaves/binary doublings (`base: 2`), a half-decade "1-3-10" grid (`divisions: [1, 3]`), or any other per-`base`-cycle split (#18) +- `numericRangeGroup(step, unit?, missingLabel?)`/`datePartGroup(part, parseDate?, missingLabel?)`/`logRangeGroup(options?, unit?, missingLabel?)` (core, re-exported from every adapter) — each bundles a bucketer with its matching formatter into one `{ groupValue, groupFormat }` pair from a single set of arguments, spreadable directly into a column def (`{ key: 'hoursPlayed', ...logRangeGroup({ divisions: [1, 3] }) }`), removing the config-divergence risk of passing the same `step`/`unit`/`part`/`options` to both halves separately (#18) + +### Fixed + +- `bucketNumericRange`/`bucketDatePart` now return `null` for a missing (`null`/`undefined`) value instead of silently coercing it — `bucketNumericRange` previously read `Number(null) === 0`, merging "no value" into the same group as a real, confirmed `0`; `bucketDatePart` previously read `String(null) === "null"`, surfacing the literal text `"null"` as a group header. `bucketNumericRange` also now returns `null` (rather than `NaN`) for a non-numeric value, since `NaN` previously flowed through to a group key that stringified to the literal visible text `"NaN"`. `formatNumericRange`/`formatDatePart` each gained a 3rd `missingLabel = '(none)'` parameter rendered for that group (#18) + ## [0.10.0] - 2026-08-21 ### Added diff --git a/CLAUDE.md b/CLAUDE.md index 389e078..62cc8bf 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -236,7 +236,15 @@ When a column is added to `groupBy`, `useTableState` removes it from `activeColu `ColumnDefBase.keepVisibleWhenGrouped?: boolean` (default `false`) opts a column out of that auto-hide (GitHub issue #16) — for the two cases where the group header no longer shows the same thing the row would, so hiding is a net information loss rather than a redundancy: a `groupValue`-bucketed column (header shows only the bucket, e.g. "3–10h" — the row's exact value is otherwise visible nowhere) and a multi-value/array column (grouping fans a row out into one group per value, so hiding the column removes the only way to see a row's _other_ values from within one particular group's expansion). Deliberately explicit rather than auto-detected off `groupValue`/array-shaped values — `value` is an arbitrary function, so whether a column's cells can be arrays isn't knowable from the column def alone, only from actual data. `activeColumns`' filter (identical across React/Vue/Solid — vanilla gets it for free) became `visibleCols.has(c.key) && (!groupBy.includes(c.key) || c.keepVisibleWhenGrouped === true)`; rendering when kept visible is unchanged (the same `format`/render/slot pipeline as ungrouped, showing the row's real value — including the full array for a multi-value column — with no filtering-out of the value that matched the current group). -`ColumnDefBase.groupValue?: (value, row) => unknown` lets a column bucket into a coarser group key than its exact value (e.g. a percentage rounded to a 10-point range, a timestamp truncated to its month) — spliced into `groupData` right where the exact-value key would otherwise be read, affecting grouping only. `ColumnDefBase.groupFormat?: (keyPart) => string` renders that bucket key for the group header, bypassing the column's normal `format`/render/slot pipeline. `bucketNumericRange`/`formatNumericRange`/`bucketDatePart`/`formatDatePart` (core, re-exported from every adapter) are ready-made pairs for the two common cases. +`ColumnDefBase.groupValue?: (value, row) => unknown` lets a column bucket into a coarser group key than its exact value (e.g. a percentage rounded to a 10-point range, a timestamp truncated to its month) — spliced into `groupData` right where the exact-value key would otherwise be read, affecting grouping only. `ColumnDefBase.groupFormat?: (keyPart) => string` renders that bucket key for the group header, bypassing the column's normal `format`/render/slot pipeline. `bucketNumericRange`/`formatNumericRange`/`bucketDatePart`/`formatDatePart`/`bucketLogRange`/`formatLogRange` (core, re-exported from every adapter) are ready-made pairs for the common cases — see "Ready-made `groupValue` bucketers" below. + +### Ready-made `groupValue` bucketers (GitHub issue #18) + +`bucketNumericRange(step)`/`bucketDatePart(part, parseDate?)` return `null` for a missing (`null`/`undefined`) or (for the numeric one) non-numeric value, rather than coercing it — `Number(null) === 0` would otherwise silently merge "no value" into the same bucket as a real, confirmed `0`, and `Number(undefined)`/`Number('abc')` being `NaN`, or `String(null) === "null"`, would otherwise flow through `groupData`'s `multiValues` to a group key that stringifies to the literal visible text `"NaN"`/`"null"`. `null` stringifies to `''` the same way an _unbucketed_ missing scalar already does (`multiValues`' `String(value ?? '')`), so a bucketed column's missing-value group lands on the exact same key an unbucketed column's missing rows already use — no new sentinel introduced. `formatNumericRange`/`formatDatePart` each take a 3rd `missingLabel = '(none)'` param and render it for that `''` key; a genuinely invalid _but present_ value (e.g. `bucketDatePart`'s raw string when it doesn't parse as a date) still returns unchanged, since — unlike a missing value — it's real text worth keeping. + +`bucketLogRange(options?)`/`formatLogRange(options?, unit?, missingLabel?)` bucket a `type: 'number'` column on a logarithmic scale instead of a linear step — for a right-skewed column spanning several orders of magnitude (review counts, hours played, file sizes), where any single linear `bucketNumericRange` step is either too coarse for the long tail or too fine for the low end. `LogRangeOptions` is `{ base?, divisions?, min? }`: `base` (default `10`) is the multiplier per exponent step (`2` for octaves/binary doublings); `divisions` (default `[1]`, plain order-of-magnitude) lists the bucket starts within one `base` cycle as multipliers of `base ** exponent` — `[1, 3]` splits each decade into a "1–3"/"3–10" half-decade grid, `[1, 2, 5]` gives the classic "1-2-5" grid; `min` (default `1`) collapses everything below it (and always `<= 0`, since `log` is undefined there) into one low bucket instead of extending the grid toward zero — pass `min: 0` to opt out of the collapse for positive values (zero/negative still always collapse, regardless of `min`). The below-`min` bucket uses a dedicated `-Infinity` sentinel, kept distinct from the `null` "missing" key so the two don't merge; `formatLogRange` renders it as `"<"`. Bucket boundaries are always an exact `base ** exponent * divisions[i]`, so `formatLogRange`'s k/M magnitude-suffix formatting (`>= 1e3` → `k`, `>= 1e6` → `M`) never needs decimal rounding. No named preset division arrays (e.g. IEC 60063's E-series, ISO 3's Renard series) ship built in — `divisions` accepts any array, and a consumer needing a named standard passes its values directly. + +`numericRangeGroup(step, unit?, missingLabel?)`/`datePartGroup(part, parseDate?, missingLabel?)`/`logRangeGroup(options?, unit?, missingLabel?)` each return `{ groupValue, groupFormat }` — the exact property names `ColumnDefBase` uses — from one set of arguments, since `groupValue`/`groupFormat` otherwise need the same `step`/`unit`, `part`, or `options`/`unit` passed to both independently; a mismatch between the two (e.g. changing `step` on one side and forgetting the other) previously failed silently, producing a group header that disagreed with its own bucket's real boundaries. Spread directly into a column def: `{ key: 'hoursPlayed', ...logRangeGroup({ divisions: [1, 3] }, 'h') }`. The standalone `bucket*`/`format*` functions stay exported too, for a consumer that wants just one half or needs to pass mismatched-on-purpose arguments (e.g. a `unit` that only applies to the header, not the bucketing itself — not a real case today, but the two were never coupled at the type level). `defaultGroupsCollapsed` (default `true`) controls initial collapse state via `isGroupCollapsed(collapsedGroups, key, defaultCollapsed)` — `collapsedGroups` is reinterpreted as _manual toggles away from the default_ rather than absolute state, so a never-seen group key picks up the default for free with no seeding needed. diff --git a/docs/grouped-columns.md b/docs/grouped-columns.md index 6129bb6..b6dc441 100644 --- a/docs/grouped-columns.md +++ b/docs/grouped-columns.md @@ -4,7 +4,7 @@ When a column is added to `groupBy`, `useTableState` removes it from `activeColu **Keeping a grouped column's cells visible (`keepVisibleWhenGrouped`)** — the auto-hide above assumes the group header already shows what the row would, making the row's copy redundant. Two cases break that assumption (GitHub issue #16): a `groupValue`-bucketed column (see below), where the header shows only the bucket label (e.g. `"3–10h"`) and the row's exact value (`"4.3h"`) is otherwise displayed nowhere at all; and a multi-value/array column, where grouping fans one row out into a group per value (a `["Roguelike", "Deckbuilder"]` row appears in both groups), so hiding the column removes the only way to see a row's _other_ values while looking at one particular group's rows. `ColumnDefBase.keepVisibleWhenGrouped?: boolean` (default `false`) opts a column out of the hide for exactly these cases: `activeColumns`' filter (identical in React/Vue/Solid; vanilla inherits it from Solid) is `visibleCols.has(c.key) && (!groupBy.includes(c.key) || c.keepVisibleWhenGrouped === true)`. This is a per-column, explicit opt-in rather than an automatic one — a column's cell values aren't knowable to be bucketed/array-shaped from the column def alone, since `value` is an arbitrary function; whether it _can_ return an array is a fact about actual data, not something safe to infer once and apply everywhere. Rendering itself is unchanged when kept visible: the row's cell goes through the exact same `format`/render/slot pipeline as it would ungrouped (including rendering the full array for a multi-value column) — there's no separate "show only the values other than the one that matched this group" logic; a consumer wanting that presentation can build it with a custom `render`/format function. -**Bucketed grouping (`groupValue`/`groupFormat`)** — grouping buckets rows by a groupBy column's exact value, which is meaningless for a continuous or near-unique column (a percentage, a raw timestamp): every row lands in its own group. `ColumnDefBase.groupValue?: (value: unknown, row: TRow) => unknown` lets a column bucket into a coarser group key instead — e.g. a percentage rounded down to a 10-point range, or a timestamp truncated to its month. It's spliced into `groupData` right where the exact-value key would otherwise be read (`col.groupValue ? col.groupValue(raw, row) : raw`, then through the same `multiValues`/fan-out path unchanged), so it affects grouping _only_ — sort/filter/aggregate/cell rendering keep reading the column's real value via `getColumnValue`, untouched. The bucket key still flows through `keyParts`, so `sortWithinGroups`' existing type-aware group comparison (`comparableFromKeyPart`, keyed off `col.type`) orders bucketed groups correctly for free — a `groupValue` returning a number for a `type: 'number'` column, or a `parseDate`-parseable string for `type: 'date'`, needs no separate sort key. The group header, however, can't just render a sample row's real value through the column's normal `format`/cell pipeline as it does for exact-value grouping — a bucket's representative row's real value (e.g. `47%`) isn't the bucket it's displayed under (`"40–50%"`). `ColumnDefBase.groupFormat?: (keyPart: string) => string` renders the bucket key for the group header instead (falling back to the raw key when omitted); a bucketed column's group header renders `gCol.groupFormat?.(keyParts[gi]) ?? keyParts[gi]` directly, bypassing every other per-column display hook it would otherwise go through (React's `col.render`/`col.format` via `formatValue`; Vue's `#group-{key}` slot, which has no single raw value to hand the slot's scope in the first place — the same reasoning the date filter tree already skips its own per-value slot for a branch node; vanilla's `formatStr`/`col.render`). `bucketNumericRange(step)`/`formatNumericRange(step, unit?)` and `bucketDatePart(part)`/`formatDatePart(part)` (core, `part: 'year' | 'month' | 'day'`) are ready-made pairs for the two common cases, re-exported from each adapter package (alongside `DEFAULT_LABELS`/locales) since — unlike most other core pure functions, which stay internal to each adapter's own state/render logic — these are meant for direct use in a consumer's column definitions. Vue's `DataTableView.vue` renames its own pre-existing group-header helper from `groupValue(group, key, i)` to `groupRawValue(...)` to avoid colliding with the new `ColumnDefBase.groupValue` column property; the bucketed-label path is a separate `groupBucketLabel(group, key, i)` helper. +**Bucketed grouping (`groupValue`/`groupFormat`)** — grouping buckets rows by a groupBy column's exact value, which is meaningless for a continuous or near-unique column (a percentage, a raw timestamp): every row lands in its own group. `ColumnDefBase.groupValue?: (value: unknown, row: TRow) => unknown` lets a column bucket into a coarser group key instead — e.g. a percentage rounded down to a 10-point range, or a timestamp truncated to its month. It's spliced into `groupData` right where the exact-value key would otherwise be read (`col.groupValue ? col.groupValue(raw, row) : raw`, then through the same `multiValues`/fan-out path unchanged), so it affects grouping _only_ — sort/filter/aggregate/cell rendering keep reading the column's real value via `getColumnValue`, untouched. The bucket key still flows through `keyParts`, so `sortWithinGroups`' existing type-aware group comparison (`comparableFromKeyPart`, keyed off `col.type`) orders bucketed groups correctly for free — a `groupValue` returning a number for a `type: 'number'` column, or a `parseDate`-parseable string for `type: 'date'`, needs no separate sort key. The group header, however, can't just render a sample row's real value through the column's normal `format`/cell pipeline as it does for exact-value grouping — a bucket's representative row's real value (e.g. `47%`) isn't the bucket it's displayed under (`"40–50%"`). `ColumnDefBase.groupFormat?: (keyPart: string) => string` renders the bucket key for the group header instead (falling back to the raw key when omitted); a bucketed column's group header renders `gCol.groupFormat?.(keyParts[gi]) ?? keyParts[gi]` directly, bypassing every other per-column display hook it would otherwise go through (React's `col.render`/`col.format` via `formatValue`; Vue's `#group-{key}` slot, which has no single raw value to hand the slot's scope in the first place — the same reasoning the date filter tree already skips its own per-value slot for a branch node; vanilla's `formatStr`/`col.render`). `bucketNumericRange(step)`/`formatNumericRange(step, unit?)`, `bucketDatePart(part)`/`formatDatePart(part)` (core, `part: 'year' | 'month' | 'day'`), and `bucketLogRange(options?)`/`formatLogRange(options?, unit?)` (log-scale bucketing, for a right-skewed numeric column spanning several orders of magnitude) are ready-made pairs, re-exported from each adapter package (alongside `DEFAULT_LABELS`/locales) since — unlike most other core pure functions, which stay internal to each adapter's own state/render logic — these are meant for direct use in a consumer's column definitions. `numericRangeGroup`/`datePartGroup`/`logRangeGroup` bundle each bucketer with its formatter into one `{ groupValue, groupFormat }` pair from a single set of arguments, spreadable straight into a column def. All three bucketers return `null` for a missing (`null`/`undefined`) value instead of coercing it into a real bucket (e.g. a real `0`) or leaking a raw `"null"`/`"NaN"` group header — see "Ready-made `groupValue` bucketers" in [CLAUDE.md](../CLAUDE.md#ready-made-groupvalue-bucketers-github-issue-18) for the full mechanics of all three (GitHub issue #18). Vue's `DataTableView.vue` renames its own pre-existing group-header helper from `groupValue(group, key, i)` to `groupRawValue(...)` to avoid colliding with the new `ColumnDefBase.groupValue` column property; the bucketed-label path is a separate `groupBucketLabel(group, key, i)` helper. A `defaultGroupsCollapsed` option/prop (a `UseTableStateOptions`/`DataTableOptions` field in all three adapters) controls whether groups start expanded or collapsed, **defaulting to `true`** (collapsed) — pass `false` to start expanded. Rather than seeding `collapsedGroups` with every group key up front (impossible anyway — group keys are only known once the data is actually grouped, and new keys can appear later as data changes), `collapsedGroups: Set` is reinterpreted as _manual toggles away from the default_ rather than absolute collapsed state: `isGroupCollapsed(collapsedGroups, key, defaultCollapsed)` (core, itself defaulting to `false` as a neutral pure-function default) is `defaultCollapsed ? !collapsedGroups.has(key) : collapsedGroups.has(key)`, used everywhere collapse state is checked (`getVisibleRows`, and each adapter's own render of the header's expand icon/aria-expanded and its rows' visibility) in place of a bare `.has(key)`. This means a never-toggled group — including one that's never been seen before — picks up `defaultCollapsed` for free, with no extra "seed new groups" bookkeeping. `toggleCollapse` itself is unchanged (still a plain Set add/remove); only its interpretation flips. diff --git a/docs/solid-package.md b/docs/solid-package.md index 360dbbb..1cd5a06 100644 --- a/docs/solid-package.md +++ b/docs/solid-package.md @@ -13,7 +13,7 @@ Rationale for building this adapter with Solid + TSX in the first place (rather - **`components/`** — `Dropdown.tsx` (shared open/close/outside-click/Escape/viewport-clamping shell used by all four toolbar dropdowns), `SearchBox.tsx`, `ColumnsDropdown.tsx`, `SortDropdown.tsx`, `GroupDropdown.tsx`, `FilterDropdown.tsx` (the master-detail panel — checklist/range+slider/date-tree), `RangeSlider.tsx`, `formatRangeBound.ts` (converts a numeric/date bound back into the string shape `RangeFilter.min`/`.max` expects, shared by `RangeSlider.tsx`'s thumb-commit and `FilterDropdown.tsx`'s plain min/max inputs so the two stay in sync), `DateTreeItem.tsx` (self-importing recursive tree node, same pattern as Vue's own), `ActiveBar.tsx`, `TableBody.tsx` (header + group/data rows + aggregation + keyboard nav), `Pagination.tsx`, `dragReorder.ts` (shared cursor-position-based drop-row resolution for Sort/Group/Columns drag-and-drop and `TableBody`'s header drag, reusing the same helper via a horizontal variant), `checkboxSync.ts` (see below). - **`DataTable.tsx`** — thin convenience wrapper mirroring React's/Vue's own ``: builds a `createTableState` internally (passing `data`/`columns` through as accessors — `() => props.data` — so no manual `createEffect` is needed here the way every other adapter's equivalent wrapper needs one) and renders ``. `DataTableProps` is `Omit, 'table'>` plus its own `data`/`columns` (needed here even though `DataTableViewProps` doesn't have them — `` still has to feed them into `createTableState` itself) plus the same four construction-only fields (`defaultVisibleColumns`, `labels`, `defaultPageSize`, `defaultGroupsCollapsed`) React/Vue's own `DataTableProps` add, plus `onSelectionChange?: (rows: TRow[]) => void` — the one prop `DataTableViewProps` doesn't otherwise have, needed here specifically because `` never hands `table` back to its caller the way the split does, so there's no other way to observe a selection change (same reason `@vates/data-table-vanilla`'s `createDataTable` has its own `onSelectionChange` option). Wired via `createEffect(on(table.selection.rows, ..., { defer: true }))`, the same pattern vanilla's `index.tsx` already uses. - **`persistence.ts`** — `usePersistedView`/`useUrlView`/`resetView`/`usePersistence`, a straight promotion of the hand-rolled `createEffect`-based implementation that used to live only in `demo/solid/src/persistence.ts` (that file is gone now that the real thing exists here) — same behavior and naming as React/Vue's own hooks (see "View persistence" below), including the empty-view-removes-the-`localStorage`-key fix and the combined `usePersistence` helper that came later. -- **`index.ts`** — re-exports `createTableState`/`TableState`/`CreateTableStateOptions`, `DataTableView`/`DataTableViewProps`, `DataTable`/`DataTableProps`, `ColumnDef`, `usePersistedView`/`useUrlView`/`resetView`/`usePersistence` and their option types, and (mirroring every other adapter) core's locales, `DataTableLabels`/`TableViewState`/`GetRowId`/`DatePart`, and the `bucketNumericRange`/`formatNumericRange`/`bucketDatePart`/`formatDatePart`/`compareMissingLast` helpers. `injectStyles` is deliberately **not** re-exported here — `DataTableView` already calls it automatically on mount, so a normal consumer never needs it, and there's no documented reason (e.g. SSR pre-injection) to justify the extra public surface. It's still exported from `./styles` itself for `DataTableView`'s own internal import. +- **`index.ts`** — re-exports `createTableState`/`TableState`/`CreateTableStateOptions`, `DataTableView`/`DataTableViewProps`, `DataTable`/`DataTableProps`, `ColumnDef`, `usePersistedView`/`useUrlView`/`resetView`/`usePersistence` and their option types, and (mirroring every other adapter) core's locales, `DataTableLabels`/`TableViewState`/`GetRowId`/`DatePart`/`LogRangeOptions`, and the `bucketNumericRange`/`formatNumericRange`/`numericRangeGroup`/`bucketDatePart`/`formatDatePart`/`datePartGroup`/`bucketLogRange`/`formatLogRange`/`logRangeGroup`/`compareMissingLast` helpers. `injectStyles` is deliberately **not** re-exported here — `DataTableView` already calls it automatically on mount, so a normal consumer never needs it, and there's no documented reason (e.g. SSR pre-injection) to justify the extra public surface. It's still exported from `./styles` itself for `DataTableView`'s own internal import. **Known simplifications vs. the fuller documented behavior below** (deliberately deferred, not silently dropped — each is noted in its own component's doc comment too): the flat filter checklist is not virtualized/windowed (`computeVirtualRange` exists in core for this, layerable later — a pure rendering-cost optimization, not a behavior change); the generic roving Up/Down/Home/End keyboard nav _inside an open dropdown_ (beyond native Tab order), dropdown focus-on-open, and Sort/Group's activate/remove focus-retention are not implemented; the Filter dropdown's Left/Right pane-crossing keyboard nav is not implemented; `TableBody`'s Home/End jump to the first/last item of the _current page only_ — crossing a page boundary (arrow keys, or Ctrl+Home/Ctrl+End across all pages) is deferred. None of this affects mouse/touch interaction or the underlying state/behavior — every one of these is purely about keyboard-only navigation shortcuts layered on top of already-working mouse interactions, native Tab order, and (where relevant) already-working same-page keyboard nav. diff --git a/packages/core/README.md b/packages/core/README.md index e5a1b93..a4c7d16 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -97,10 +97,16 @@ toggleCollapse(collapsedGroups, key) // toggle a collapsed group isGroupCollapsed(collapsedGroups, key, defaultCollapsed?) // whether key is collapsed; collapsedGroups tracks manual toggles away from defaultCollapsed, not absolute state countActiveGroups(groupBy) // groupBy.length, exported for symmetry with countActiveFilters/countActiveSorts DatePart // 'year' | 'month' | 'day' — granularity for bucketDatePart/formatDatePart -bucketNumericRange(step) // ready-made groupValue: rounds a number down to the start of its step-wide range -formatNumericRange(step, unit?) // formats a bucketNumericRange key as "" -bucketDatePart(part, parseDate?) // ready-made groupValue: truncates a date to the start of its enclosing year/month/day, as an ISO string -formatDatePart(part) // formats a bucketDatePart key for display, e.g. "2024-05-01" -> "May 2024" +bucketNumericRange(step) // ready-made groupValue: rounds a number down to the start of its step-wide range; null for a missing/non-numeric value +formatNumericRange(step, unit?, missingLabel?) // formats a bucketNumericRange key as ""; missingLabel (default '(none)') for the missing-value group +numericRangeGroup(step, unit?, missingLabel?) // { groupValue, groupFormat } pair from one set of args, spreadable into a column def +bucketDatePart(part, parseDate?) // ready-made groupValue: truncates a date to the start of its enclosing year/month/day, as an ISO string; null for a missing value +formatDatePart(part, missingLabel?) // formats a bucketDatePart key for display, e.g. "2024-05-01" -> "May 2024"; missingLabel (default '(none)') for the missing-value group +datePartGroup(part, parseDate?, missingLabel?) // { groupValue, groupFormat } pair from one set of args +LogRangeOptions // { base?, divisions?, min? } — see bucketLogRange +bucketLogRange(options?) // ready-made groupValue: buckets on a log scale (base 10 decades by default; divisions splits each power of base, e.g. [1, 3] for a half-decade "1-3-10" grid); values < min (default 1) collapse into one low bucket, pass min: 0 to opt out +formatLogRange(options?, unit?, missingLabel?) // formats a bucketLogRange key, with k/M magnitude suffixes and a "<" label for the below-min bucket +logRangeGroup(options?, unit?, missingLabel?) // { groupValue, groupFormat } pair from one set of args ``` See [docs/grouped-columns.md](../../docs/grouped-columns.md) for the full mechanics (fan-out, aggregation, bucketing). diff --git a/packages/react/README.md b/packages/react/README.md index bd6dae6..0721076 100644 --- a/packages/react/README.md +++ b/packages/react/README.md @@ -190,7 +190,33 @@ import { bucketNumericRange, formatNumericRange, bucketDatePart, formatDatePart } ``` -`groupValue(value, row)` returns the bucket key — return a value whose type matches `col.type` (a number for `type: 'number'`, a `parseDate`-parseable string for `type: 'date'`) so groups still sort correctly, the same type-aware comparison a plain groupBy column already gets. `groupFormat(keyPart)` renders that bucket key for the group header (`bucketNumericRange`'s lower bound alone, e.g. `40000`, usually isn't fit to display on its own); omit it to show the raw bucket key. Unlike a plain groupBy column, a bucketed column's group header doesn't call `render`/`format` — `groupFormat` is the only display hook for it. `bucketDatePart`/`formatDatePart` accept `'year' | 'month' | 'day'` granularity. +`groupValue(value, row)` returns the bucket key — return a value whose type matches `col.type` (a number for `type: 'number'`, a `parseDate`-parseable string for `type: 'date'`) so groups still sort correctly, the same type-aware comparison a plain groupBy column already gets. `groupFormat(keyPart)` renders that bucket key for the group header (`bucketNumericRange`'s lower bound alone, e.g. `40000`, usually isn't fit to display on its own); omit it to show the raw bucket key. Unlike a plain groupBy column, a bucketed column's group header doesn't call `render`/`format` — `groupFormat` is the only display hook for it. `bucketDatePart`/`formatDatePart` accept `'year' | 'month' | 'day'` granularity. Both bucketers return `null` for a missing (`null`/`undefined`) value rather than miscounting it (e.g. `Number(null) === 0` would otherwise merge "no value" into the real `0` bucket) — `groupFormat` renders that group as `'(none)'` by default, overridable via a 3rd `missingLabel` argument. + +For a right-skewed column spanning several orders of magnitude (review counts, hours played, file sizes), where a single linear step is either too coarse for the long tail or too fine for the low end, `bucketLogRange`/`formatLogRange` bucket on a log scale instead: + +```tsx +import { bucketLogRange, formatLogRange } from '@vates/data-table-react' + +{ + key: 'hoursPlayed', + label: 'Hours played', + type: 'number', + groupable: true, + groupValue: bucketLogRange({ divisions: [1, 3] }), // 47 -> 30 (a half-decade "1-3-10" grid) + groupFormat: formatLogRange({ divisions: [1, 3] }, 'h'), // "30–100h" in the group header +} +``` + +`divisions` (default `[1]`, plain order-of-magnitude) lists the bucket starts within one power of `base` (default `10`) — `[1, 3]` above gives a half-decade grid, `[1, 2, 5]` the classic "1-2-5" grid; `base: 2` with the default `[1]` buckets by octave/binary doubling instead of decades. `min` (default `1`) collapses everything below it into one low bucket instead of extending the grid toward zero (`log` is undefined at/below `0` regardless) — pass `min: 0` to keep bucketing all the way down to (but not including) zero. + +Since `groupValue`/`groupFormat` need the same arguments (`step`/`unit`, `part`, or `options`/`unit`) passed twice, a typo or later edit to just one side silently produces a group header that disagrees with its own bucket's real boundaries. `numericRangeGroup`/`datePartGroup`/`logRangeGroup` remove that risk by bundling both from one call, spreadable directly into a column def: + +```tsx +import { numericRangeGroup, logRangeGroup } from '@vates/data-table-react' + +{ key: 'salary', label: 'Salary', type: 'number', groupable: true, ...numericRangeGroup(20000, ' USD') } +{ key: 'hoursPlayed', label: 'Hours played', type: 'number', groupable: true, ...logRangeGroup({ divisions: [1, 3] }, 'h') } +``` A grouped column normally disappears from the row cells too, since its value is already shown in the group header — but that's a loss for a bucketed column (the header only shows `"40000–60000 USD"`, not the row's exact `47000`) or a multi-value column (a `["Roguelike", "Deckbuilder"]` row shows up in both groups, and hiding the column removes the only way to see its _other_ tags from within one group). Set `keepVisibleWhenGrouped: true` on such a column to keep it in the row cells even while grouped: diff --git a/packages/vanilla/README.md b/packages/vanilla/README.md index 6a9559c..2e28992 100644 --- a/packages/vanilla/README.md +++ b/packages/vanilla/README.md @@ -262,7 +262,33 @@ import { bucketNumericRange, formatNumericRange, bucketDatePart, formatDatePart } ``` -`groupValue(value, row)` returns the bucket key — return a value whose type matches `col.type` (a number for `type: 'number'`, a `parseDate`-parseable string for `type: 'date'`) so groups still sort correctly, the same type-aware comparison a plain groupBy column already gets. `groupFormat(keyPart)` renders that bucket key for the group header (`bucketNumericRange`'s lower bound alone, e.g. `40000`, usually isn't fit to display on its own); omit it to show the raw bucket key. `bucketDatePart`/`formatDatePart` accept `'year' | 'month' | 'day'` granularity. +`groupValue(value, row)` returns the bucket key — return a value whose type matches `col.type` (a number for `type: 'number'`, a `parseDate`-parseable string for `type: 'date'`) so groups still sort correctly, the same type-aware comparison a plain groupBy column already gets. `groupFormat(keyPart)` renders that bucket key for the group header (`bucketNumericRange`'s lower bound alone, e.g. `40000`, usually isn't fit to display on its own); omit it to show the raw bucket key. `bucketDatePart`/`formatDatePart` accept `'year' | 'month' | 'day'` granularity. Both bucketers return `null` for a missing (`null`/`undefined`) value rather than miscounting it (e.g. `Number(null) === 0` would otherwise merge "no value" into the real `0` bucket) — `groupFormat` renders that group as `'(none)'` by default, overridable via a 3rd `missingLabel` argument. + +For a right-skewed column spanning several orders of magnitude (review counts, hours played, file sizes), where a single linear step is either too coarse for the long tail or too fine for the low end, `bucketLogRange`/`formatLogRange` bucket on a log scale instead: + +```ts +import { bucketLogRange, formatLogRange } from '@vates/data-table-vanilla' + +{ + key: 'hoursPlayed', + label: 'Hours played', + type: 'number', + groupable: true, + groupValue: bucketLogRange({ divisions: [1, 3] }), // 47 -> 30 (a half-decade "1-3-10" grid) + groupFormat: formatLogRange({ divisions: [1, 3] }, 'h'), // "30–100h" in the group header +} +``` + +`divisions` (default `[1]`, plain order-of-magnitude) lists the bucket starts within one power of `base` (default `10`) — `[1, 3]` above gives a half-decade grid, `[1, 2, 5]` the classic "1-2-5" grid; `base: 2` with the default `[1]` buckets by octave/binary doubling instead of decades. `min` (default `1`) collapses everything below it into one low bucket instead of extending the grid toward zero (`log` is undefined at/below `0` regardless) — pass `min: 0` to keep bucketing all the way down to (but not including) zero. + +Since `groupValue`/`groupFormat` need the same arguments (`step`/`unit`, `part`, or `options`/`unit`) passed twice, a typo or later edit to just one side silently produces a group header that disagrees with its own bucket's real boundaries. `numericRangeGroup`/`datePartGroup`/`logRangeGroup` remove that risk by bundling both from one call, spreadable directly into a column def: + +```ts +import { numericRangeGroup, logRangeGroup } from '@vates/data-table-vanilla' + +{ key: 'salary', label: 'Salary', type: 'number', groupable: true, ...numericRangeGroup(20000, ' USD') } +{ key: 'hoursPlayed', label: 'Hours played', type: 'number', groupable: true, ...logRangeGroup({ divisions: [1, 3] }, 'h') } +``` A grouped column normally disappears from the row cells too, since its value is already shown in the group header — but that's a loss for a bucketed column (the header only shows `"40000–60000 USD"`, not the row's exact `47000`) or a multi-value column (a `["Roguelike", "Deckbuilder"]` row shows up in both groups, and hiding the column removes the only way to see its _other_ tags from within one group). Set `keepVisibleWhenGrouped: true` on such a column to keep it in the row cells even while grouped: diff --git a/packages/vue/README.md b/packages/vue/README.md index babd586..5485d7f 100644 --- a/packages/vue/README.md +++ b/packages/vue/README.md @@ -207,7 +207,33 @@ import { bucketNumericRange, formatNumericRange, bucketDatePart, formatDatePart } ``` -`groupValue(value, row)` returns the bucket key — return a value whose type matches `col.type` (a number for `type: 'number'`, a `parseDate`-parseable string for `type: 'date'`) so groups still sort correctly, the same type-aware comparison a plain groupBy column already gets. `groupFormat(keyPart)` renders that bucket key for the group header (`bucketNumericRange`'s lower bound alone, e.g. `40000`, usually isn't fit to display on its own); omit it to show the raw bucket key. A bucketed column's group header bypasses the `#group-{key}` slot entirely (there's no single raw value the slot's scope could meaningfully carry) — `groupFormat` is the only display hook for it. `bucketDatePart`/`formatDatePart` accept `'year' | 'month' | 'day'` granularity. +`groupValue(value, row)` returns the bucket key — return a value whose type matches `col.type` (a number for `type: 'number'`, a `parseDate`-parseable string for `type: 'date'`) so groups still sort correctly, the same type-aware comparison a plain groupBy column already gets. `groupFormat(keyPart)` renders that bucket key for the group header (`bucketNumericRange`'s lower bound alone, e.g. `40000`, usually isn't fit to display on its own); omit it to show the raw bucket key. A bucketed column's group header bypasses the `#group-{key}` slot entirely (there's no single raw value the slot's scope could meaningfully carry) — `groupFormat` is the only display hook for it. `bucketDatePart`/`formatDatePart` accept `'year' | 'month' | 'day'` granularity. Both bucketers return `null` for a missing (`null`/`undefined`) value rather than miscounting it (e.g. `Number(null) === 0` would otherwise merge "no value" into the real `0` bucket) — `groupFormat` renders that group as `'(none)'` by default, overridable via a 3rd `missingLabel` argument. + +For a right-skewed column spanning several orders of magnitude (review counts, hours played, file sizes), where a single linear step is either too coarse for the long tail or too fine for the low end, `bucketLogRange`/`formatLogRange` bucket on a log scale instead: + +```ts +import { bucketLogRange, formatLogRange } from '@vates/data-table-vue' + +{ + key: 'hoursPlayed', + label: 'Hours played', + type: 'number', + groupable: true, + groupValue: bucketLogRange({ divisions: [1, 3] }), // 47 -> 30 (a half-decade "1-3-10" grid) + groupFormat: formatLogRange({ divisions: [1, 3] }, 'h'), // "30–100h" in the group header +} +``` + +`divisions` (default `[1]`, plain order-of-magnitude) lists the bucket starts within one power of `base` (default `10`) — `[1, 3]` above gives a half-decade grid, `[1, 2, 5]` the classic "1-2-5" grid; `base: 2` with the default `[1]` buckets by octave/binary doubling instead of decades. `min` (default `1`) collapses everything below it into one low bucket instead of extending the grid toward zero (`log` is undefined at/below `0` regardless) — pass `min: 0` to keep bucketing all the way down to (but not including) zero. + +Since `groupValue`/`groupFormat` need the same arguments (`step`/`unit`, `part`, or `options`/`unit`) passed twice, a typo or later edit to just one side silently produces a group header that disagrees with its own bucket's real boundaries. `numericRangeGroup`/`datePartGroup`/`logRangeGroup` remove that risk by bundling both from one call, spreadable directly into a column def: + +```ts +import { numericRangeGroup, logRangeGroup } from '@vates/data-table-vue' + +{ key: 'salary', label: 'Salary', type: 'number', groupable: true, ...numericRangeGroup(20000, ' USD') } +{ key: 'hoursPlayed', label: 'Hours played', type: 'number', groupable: true, ...logRangeGroup({ divisions: [1, 3] }, 'h') } +``` A grouped column normally disappears from the row cells too, since its value is already shown in the group header — but that's a loss for a bucketed column (the header only shows `"40000–60000 USD"`, not the row's exact `47000`) or a multi-value column (a `["Roguelike", "Deckbuilder"]` row shows up in both groups, and hiding the column removes the only way to see its _other_ tags from within one group). Set `keepVisibleWhenGrouped: true` on such a column to keep it in the row cells even while grouped: From 7ec605535ee92e0747062263517451e8958165c5 Mon Sep 17 00:00:00 2001 From: Julien Fontanet Date: Fri, 21 Aug 2026 15:37:15 +0200 Subject: [PATCH 4/4] demo: showcase paired groupValue/groupFormat helpers and log-scale bucketing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All four demos (react/vue/solid/vanilla): - Salary/Joined columns switch from bucketNumericRange+formatNumericRange/ bucketDatePart+formatDatePart to the new numericRangeGroup/datePartGroup paired helpers. - Employee.salary becomes number | null — Eva Müller (already modeling "no review yet" via score: null) also gets salary: null (payroll not finalized yet), demonstrating the missing-value fix live: her row now lands in its own "(none)" salary group instead of being miscounted into the $0 bucket. Cell formatting/fmtSalary render "—" for a null salary. - The Huge dataset demo's Amount column becomes groupable via logRangeGroup, exercising bucketLogRange/formatLogRange over 100k rows. Co-Authored-By: Claude Sonnet 5 --- demo/react/src/App.tsx | 37 +++++++++++++++++++----------------- demo/react/src/hugeData.ts | 6 +++++- demo/solid/src/App.tsx | 37 +++++++++++++++++++----------------- demo/solid/src/hugeData.ts | 6 +++++- demo/vanilla/src/hugeData.ts | 6 +++++- demo/vanilla/src/main.ts | 31 +++++++++++++++--------------- demo/vue/src/App.vue | 37 +++++++++++++++++++----------------- demo/vue/src/hugeData.ts | 6 +++++- 8 files changed, 96 insertions(+), 70 deletions(-) diff --git a/demo/react/src/App.tsx b/demo/react/src/App.tsx index 8a44665..5d99691 100644 --- a/demo/react/src/App.tsx +++ b/demo/react/src/App.tsx @@ -6,10 +6,8 @@ import { useUrlView, resetView, usePersistence, - bucketNumericRange, - formatNumericRange, - bucketDatePart, - formatDatePart, + numericRangeGroup, + datePartGroup, compareMissingLast, LABELS_EN, LABELS_FR, @@ -28,7 +26,7 @@ interface Employee { name: string department: string role: string - salary: number + salary: number | null // null: payroll hasn't been finalized yet — bucketNumericRange/numericRangeGroup group these under their own "(none)" bucket instead of miscounting them as $0 (issue #18) joined: string status: string score: number | null // null: no performance review yet — compareMissingLast() keeps these last regardless of sort direction @@ -85,7 +83,7 @@ const SAMPLE_DATA: Employee[] = [ name: 'Eva Müller', department: 'Engineering', role: 'Junior Dev', - salary: 62000, + salary: null, // just joined, payroll not finalized yet joined: '2023-04-05', status: 'Active', score: null, // just joined, no review yet @@ -311,18 +309,22 @@ const COLUMNS: ColumnDef[] = [ type: 'number', width: 110, format: (v) => - Number(v).toLocaleString('en-US', { - style: 'currency', - currency: 'USD', - maximumFractionDigits: 0, - }), + v == null + ? '—' + : Number(v).toLocaleString('en-US', { + style: 'currency', + currency: 'USD', + maximumFractionDigits: 0, + }), aggregate: 'sum', // groupValue/groupFormat: a continuous column (near-unique per row) grouped by its exact // value would create one group per row — bucketing into $20k ranges makes it groupable // meaningfully. cell rendering/sort/filter above are untouched, still reading the real salary. + // numericRangeGroup bundles both from one call instead of passing `20000`/`' USD'` twice + // (issue #18); a null salary (Eva, just joined) lands in its own "(none)" group instead of + // being miscounted as $0. groupable: true, - groupValue: bucketNumericRange(20000), - groupFormat: formatNumericRange(20000, ' USD'), + ...numericRangeGroup(20000, ' USD'), }, // type: 'date' gets a range filter (2 inputs + a slider) above a Year › Month › Day filter // tree, instead of a plain checklist — the range narrows the tree itself. Grouped by year (not @@ -337,8 +339,7 @@ const COLUMNS: ColumnDef[] = [ width: 100, defaultSortDir: 'desc', groupable: true, - groupValue: bucketDatePart('year'), - groupFormat: formatDatePart('year'), + ...datePartGroup('year'), }, // computed column: value is a function, so there's no matching 'tenure' property on Employee — // sort/filter/group/aggregate all work off the function's return value just like a real column @@ -525,8 +526,10 @@ function DocLink({ anchor, children }: { anchor: string; children: ReactNode }) ) } -function fmtSalary(n: number) { - return n.toLocaleString('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 }) +function fmtSalary(n: number | null) { + return n == null + ? '—' + : n.toLocaleString('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 }) } // Headless section: useTableState owns the sort/filter logic; you own the render. diff --git a/demo/react/src/hugeData.ts b/demo/react/src/hugeData.ts index 44918e9..13b8f1b 100644 --- a/demo/react/src/hugeData.ts +++ b/demo/react/src/hugeData.ts @@ -1,4 +1,4 @@ -import type { ColumnDef } from '@vates/data-table-react' +import { logRangeGroup, type ColumnDef } from '@vates/data-table-react' export interface HugeRow { id: number @@ -160,6 +160,10 @@ export const HUGE_COLUMNS: ColumnDef[] = [ type: 'number', aggregate: 'sum', format: (v) => `$${Number(v).toFixed(2)}`, + // logRangeGroup: bucketing 100k rows on a log scale performs the same as bucketNumericRange + // (both are O(1) per row) — a good spot to exercise it at scale (issue #18). + groupable: true, + ...logRangeGroup({ divisions: [1, 3] }, '$'), }, { key: 'orderDate', label: 'Order Date', type: 'date' }, ] diff --git a/demo/solid/src/App.tsx b/demo/solid/src/App.tsx index 07430cb..bff3fb2 100644 --- a/demo/solid/src/App.tsx +++ b/demo/solid/src/App.tsx @@ -2,10 +2,8 @@ import { createSignal, createEffect, onMount, onCleanup, on, Show, For, type JSX import { DataTableView, createTableState, - bucketNumericRange, - formatNumericRange, - bucketDatePart, - formatDatePart, + numericRangeGroup, + datePartGroup, compareMissingLast, usePersistedView, useUrlView, @@ -28,7 +26,7 @@ interface Employee { name: string department: string role: string - salary: number + salary: number | null // null: payroll hasn't been finalized yet — bucketNumericRange/numericRangeGroup group these under their own "(none)" bucket instead of miscounting them as $0 (issue #18) joined: string status: string score: number | null // null: no performance review yet — compareMissingLast() keeps these last regardless of sort direction @@ -85,7 +83,7 @@ const SAMPLE_DATA: Employee[] = [ name: 'Eva Müller', department: 'Engineering', role: 'Junior Dev', - salary: 62000, + salary: null, // just joined, payroll not finalized yet joined: '2023-04-05', status: 'Active', score: null, // just joined, no review yet @@ -322,18 +320,22 @@ const COLUMNS: ColumnDef[] = [ type: 'number', width: 110, format: (v) => - Number(v).toLocaleString('en-US', { - style: 'currency', - currency: 'USD', - maximumFractionDigits: 0, - }), + v == null + ? '—' + : Number(v).toLocaleString('en-US', { + style: 'currency', + currency: 'USD', + maximumFractionDigits: 0, + }), aggregate: 'sum', // groupValue/groupFormat: a continuous column (near-unique per row) grouped by its exact // value would create one group per row — bucketing into $20k ranges makes it groupable // meaningfully. cell rendering/sort/filter above are untouched, still reading the real salary. + // numericRangeGroup bundles both from one call instead of passing `20000`/`' USD'` twice + // (issue #18); a null salary (Eva, just joined) lands in its own "(none)" group instead of + // being miscounted as $0. groupable: true, - groupValue: bucketNumericRange(20000), - groupFormat: formatNumericRange(20000, ' USD'), + ...numericRangeGroup(20000, ' USD'), }, // type: 'date' gets a range filter (2 inputs + a slider) above a Year › Month › Day filter // tree, instead of a plain checklist — the range narrows the tree itself. Grouped by year (not @@ -348,8 +350,7 @@ const COLUMNS: ColumnDef[] = [ width: 100, defaultSortDir: 'desc', groupable: true, - groupValue: bucketDatePart('year'), - groupFormat: formatDatePart('year'), + ...datePartGroup('year'), }, // computed column: value is a function, so there's no matching 'tenure' property on Employee — // sort/filter/group/aggregate all work off the function's return value just like a real column @@ -534,8 +535,10 @@ function DocLink(props: { anchor?: string; children: JSX.Element }) { ) } -function fmtSalary(n: number) { - return n.toLocaleString('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 }) +function fmtSalary(n: number | null) { + return n == null + ? '—' + : n.toLocaleString('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 }) } // Headless section: createTableState owns the sort/filter logic; you own the render. diff --git a/demo/solid/src/hugeData.ts b/demo/solid/src/hugeData.ts index f5b6e82..525de16 100644 --- a/demo/solid/src/hugeData.ts +++ b/demo/solid/src/hugeData.ts @@ -1,4 +1,4 @@ -import type { ColumnDef } from '@vates/data-table-solid' +import { logRangeGroup, type ColumnDef } from '@vates/data-table-solid' export interface HugeRow { id: number @@ -160,6 +160,10 @@ export const HUGE_COLUMNS: ColumnDef[] = [ type: 'number', aggregate: 'sum', format: (v) => `$${Number(v).toFixed(2)}`, + // logRangeGroup: bucketing 100k rows on a log scale performs the same as bucketNumericRange + // (both are O(1) per row) — a good spot to exercise it at scale (issue #18). + groupable: true, + ...logRangeGroup({ divisions: [1, 3] }, '$'), }, { key: 'orderDate', label: 'Order Date', type: 'date' }, ] diff --git a/demo/vanilla/src/hugeData.ts b/demo/vanilla/src/hugeData.ts index 74ea06d..f435613 100644 --- a/demo/vanilla/src/hugeData.ts +++ b/demo/vanilla/src/hugeData.ts @@ -1,4 +1,4 @@ -import type { ColumnDef } from '@vates/data-table-vanilla' +import { logRangeGroup, type ColumnDef } from '@vates/data-table-vanilla' export interface HugeRow { id: number @@ -160,6 +160,10 @@ export const HUGE_COLUMNS: ColumnDef[] = [ type: 'number', aggregate: 'sum', format: (v) => `$${Number(v).toFixed(2)}`, + // logRangeGroup: bucketing 100k rows on a log scale performs the same as bucketNumericRange + // (both are O(1) per row) — a good spot to exercise it at scale (issue #18). + groupable: true, + ...logRangeGroup({ divisions: [1, 3] }, '$'), }, { key: 'orderDate', label: 'Order Date', type: 'date' }, ] diff --git a/demo/vanilla/src/main.ts b/demo/vanilla/src/main.ts index 484e1cc..49cab8d 100644 --- a/demo/vanilla/src/main.ts +++ b/demo/vanilla/src/main.ts @@ -2,10 +2,8 @@ import { createDataTable, persistView, resetView, - bucketNumericRange, - formatNumericRange, - bucketDatePart, - formatDatePart, + numericRangeGroup, + datePartGroup, compareMissingLast, LABELS_EN, LABELS_FR, @@ -25,7 +23,7 @@ interface Employee { name: string department: string role: string - salary: number + salary: number | null // null: payroll hasn't been finalized yet — bucketNumericRange/numericRangeGroup group these under their own "(none)" bucket instead of miscounting them as $0 (issue #18) joined: string status: string score: number | null // null: no performance review yet — compareMissingLast() keeps these last regardless of sort direction @@ -82,7 +80,7 @@ const SAMPLE_DATA: Employee[] = [ name: 'Eva Müller', department: 'Engineering', role: 'Junior Dev', - salary: 62000, + salary: null, // just joined, payroll not finalized yet joined: '2023-04-05', status: 'Active', score: null, // just joined, no review yet @@ -306,18 +304,22 @@ const COLUMNS: ColumnDef[] = [ type: 'number', width: 110, format: (v) => - Number(v).toLocaleString('en-US', { - style: 'currency', - currency: 'USD', - maximumFractionDigits: 0, - }), + v == null + ? '—' + : Number(v).toLocaleString('en-US', { + style: 'currency', + currency: 'USD', + maximumFractionDigits: 0, + }), aggregate: 'sum', // groupValue/groupFormat: a continuous column (near-unique per row) grouped by its exact // value would create one group per row — bucketing into $20k ranges makes it groupable // meaningfully. cell rendering/sort/filter above are untouched, still reading the real salary. + // numericRangeGroup bundles both from one call instead of passing `20000`/`' USD'` twice + // (issue #18); a null salary (Eva, just joined) lands in its own "(none)" group instead of + // being miscounted as $0. groupable: true, - groupValue: bucketNumericRange(20000), - groupFormat: formatNumericRange(20000, ' USD'), + ...numericRangeGroup(20000, ' USD'), }, // type: 'date' gets a range filter (2 inputs + a slider) above a Year › Month › Day filter // tree, instead of a plain checklist — the range narrows the tree itself. Grouped by year (not @@ -332,8 +334,7 @@ const COLUMNS: ColumnDef[] = [ width: 100, defaultSortDir: 'desc', groupable: true, - groupValue: bucketDatePart('year'), - groupFormat: formatDatePart('year'), + ...datePartGroup('year'), }, // computed column: value is a function, so there's no matching 'tenure' property on Employee — // sort/filter/group/aggregate all work off the function's return value just like a real column diff --git a/demo/vue/src/App.vue b/demo/vue/src/App.vue index 5a20056..88ff982 100644 --- a/demo/vue/src/App.vue +++ b/demo/vue/src/App.vue @@ -7,10 +7,8 @@ import { useUrlView, resetView, usePersistence, - bucketNumericRange, - formatNumericRange, - bucketDatePart, - formatDatePart, + numericRangeGroup, + datePartGroup, compareMissingLast, LABELS_EN, LABELS_FR, @@ -30,7 +28,7 @@ interface Employee { name: string department: string role: string - salary: number + salary: number | null // null: payroll hasn't been finalized yet — bucketNumericRange/numericRangeGroup group these under their own "(none)" bucket instead of miscounting them as $0 (issue #18) joined: string status: string score: number | null // null: no performance review yet — compareMissingLast() keeps these last regardless of sort direction @@ -87,7 +85,7 @@ const SAMPLE_DATA: Employee[] = [ name: 'Eva Müller', department: 'Engineering', role: 'Junior Dev', - salary: 62000, + salary: null, // just joined, payroll not finalized yet joined: '2023-04-05', status: 'Active', score: null, // just joined, no review yet @@ -312,18 +310,22 @@ const COLUMNS: ColumnDef[] = [ type: 'number', width: 110, format: (v) => - Number(v).toLocaleString('en-US', { - style: 'currency', - currency: 'USD', - maximumFractionDigits: 0, - }), + v == null + ? '—' + : Number(v).toLocaleString('en-US', { + style: 'currency', + currency: 'USD', + maximumFractionDigits: 0, + }), aggregate: 'sum', // groupValue/groupFormat: a continuous column (near-unique per row) grouped by its exact // value would create one group per row — bucketing into $20k ranges makes it groupable // meaningfully. cell rendering/sort/filter above are untouched, still reading the real salary. + // numericRangeGroup bundles both from one call instead of passing `20000`/`' USD'` twice + // (issue #18); a null salary (Eva, just joined) lands in its own "(none)" group instead of + // being miscounted as $0. groupable: true, - groupValue: bucketNumericRange(20000), - groupFormat: formatNumericRange(20000, ' USD'), + ...numericRangeGroup(20000, ' USD'), }, // type: 'date' gets a range filter (2 inputs + a slider) above a Year › Month › Day filter // tree, instead of a plain checklist — the range narrows the tree itself. Grouped by year (not @@ -338,8 +340,7 @@ const COLUMNS: ColumnDef[] = [ width: 100, defaultSortDir: 'desc', groupable: true, - groupValue: bucketDatePart('year'), - groupFormat: formatDatePart('year'), + ...datePartGroup('year'), }, // computed column: value is a function, so there's no matching 'tenure' property on Employee — // sort/filter/group/aggregate all work off the function's return value just like a real column @@ -569,8 +570,10 @@ useUrlView(hugeTable, { paramName: VIEW_KEYS.huge.paramName }) const SORT_COLS = ['name', 'salary', 'score'] as const -function fmtSalary(n: number) { - return n.toLocaleString('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 }) +function fmtSalary(n: number | null) { + return n == null + ? '—' + : n.toLocaleString('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 }) } diff --git a/demo/vue/src/hugeData.ts b/demo/vue/src/hugeData.ts index 42bf43a..9f84eff 100644 --- a/demo/vue/src/hugeData.ts +++ b/demo/vue/src/hugeData.ts @@ -1,4 +1,4 @@ -import type { ColumnDef } from '@vates/data-table-vue' +import { logRangeGroup, type ColumnDef } from '@vates/data-table-vue' export interface HugeRow { id: number @@ -160,6 +160,10 @@ export const HUGE_COLUMNS: ColumnDef[] = [ type: 'number', aggregate: 'sum', format: (v) => `$${Number(v).toFixed(2)}`, + // logRangeGroup: bucketing 100k rows on a log scale performs the same as bucketNumericRange + // (both are O(1) per row) — a good spot to exercise it at scale (issue #18). + groupable: true, + ...logRangeGroup({ divisions: [1, 3] }, '$'), }, { key: 'orderDate', label: 'Order Date', type: 'date' }, ]