import type { AggregationFunction, CalculatedField, FieldStats, PivotConfig, PivotResult, PivotValueField, } from '@smallwebco/tinypivot-core' import { computeAvailableFields, computePivotResult, generateStorageKey, getAggregationLabel, getUnassignedFields, isConfigValidForFields, isPivotConfigured, loadCalculatedFields, loadPivotConfig, saveCalculatedFields, savePivotConfig, } from '@smallwebco/tinypivot-core' /** * Pivot Table Hook for React * Wraps core pivot logic with React state management */ import { useCallback, useEffect, useMemo, useState } from 'react' import { useLicense } from './useLicense' // Re-export for convenience export { getAggregationLabel } interface UsePivotTableReturn { // State rowFields: string[] columnFields: string[] valueFields: PivotValueField[] showRowTotals: boolean showColumnTotals: boolean calculatedFields: CalculatedField[] collapsedPaths: Set fieldFilters: Record // Computed availableFields: FieldStats[] unassignedFields: FieldStats[] isConfigured: boolean activeFieldFilters: Record pivotResult: PivotResult | null // Actions addRowField: (field: string) => void removeRowField: (field: string) => void addColumnField: (field: string) => void removeColumnField: (field: string) => void addValueField: (field: string, aggregation?: AggregationFunction) => void removeValueField: (field: string, aggregation?: AggregationFunction) => void updateValueFieldAggregation: ( field: string, oldAgg: AggregationFunction, newAgg: AggregationFunction, ) => void clearConfig: () => void setShowRowTotals: (value: boolean) => void setShowColumnTotals: (value: boolean) => void autoSuggestConfig: () => void setRowFields: (fields: string[]) => void setColumnFields: (fields: string[]) => void addCalculatedField: (field: CalculatedField) => void removeCalculatedField: (id: string) => void toggleCollapsedPath: (key: string, altKey: boolean, rowFields: string[], currentPivotResult: PivotResult | null) => Set setFieldFilter: (field: string, excludedValues: string[]) => void clearFieldFilter: (field: string) => void } /** * Main pivot table hook */ export function usePivotTable(data: Record[], enableDrillDown = true): UsePivotTableReturn { const { canUsePivot, requirePro } = useLicense() // Configuration state const [rowFields, setRowFieldsState] = useState([]) const [columnFields, setColumnFieldsState] = useState([]) const [valueFields, setValueFields] = useState([]) const [showRowTotals, setShowRowTotals] = useState(true) const [showColumnTotals, setShowColumnTotals] = useState(true) const [calculatedFields, setCalculatedFields] = useState(() => loadCalculatedFields()) const [currentStorageKey, setCurrentStorageKey] = useState(null) const [collapsedPaths, setCollapsedPaths] = useState>(new Set()) // Axis value filters: field → excluded values. Filters are kept for // unassigned fields too (so re-adding a field restores its filter), but // only filters on current row/column fields are applied to the pivot. const [fieldFilters, setFieldFilters] = useState>({}) // Compute available fields from data const availableFields = useMemo((): FieldStats[] => { return computeAvailableFields(data) }, [data]) // Get fields that haven't been assigned yet const unassignedFields = useMemo(() => { return getUnassignedFields(availableFields, rowFields, columnFields, valueFields) }, [availableFields, rowFields, columnFields, valueFields]) // Check if pivot is configured const isConfigured = useMemo(() => { return isPivotConfigured({ rowFields, columnFields, valueFields, showRowTotals, showColumnTotals, }) }, [rowFields, columnFields, valueFields, showRowTotals, showColumnTotals]) // Filters restricted to fields currently on an axis — the only ones applied const activeFieldFilters = useMemo((): Record => { const active: Record = {} for (const [field, excluded] of Object.entries(fieldFilters)) { if (excluded.length > 0 && (rowFields.includes(field) || columnFields.includes(field))) { active[field] = excluded } } return active }, [fieldFilters, rowFields, columnFields]) // Build pivot result const pivotResult = useMemo((): PivotResult | null => { if (!isConfigured) return null if (!canUsePivot) return null return computePivotResult(data, { rowFields, columnFields, valueFields, showRowTotals, showColumnTotals, calculatedFields, fieldFilters: activeFieldFilters, }, { collapsedPaths: enableDrillDown ? collapsedPaths : new Set() }) }, [data, isConfigured, canUsePivot, rowFields, columnFields, valueFields, showRowTotals, showColumnTotals, calculatedFields, activeFieldFilters, collapsedPaths, enableDrillDown]) // Load/save config from storage useEffect(() => { if (data.length === 0) return const newKeys = Object.keys(data[0]) const storageKey = generateStorageKey(newKeys) if (storageKey !== currentStorageKey) { setCurrentStorageKey(storageKey) const savedConfig = loadPivotConfig(storageKey) if (savedConfig && isConfigValidForFields(savedConfig, newKeys)) { setRowFieldsState(savedConfig.rowFields) setColumnFieldsState(savedConfig.columnFields) setValueFields(savedConfig.valueFields) setShowRowTotals(savedConfig.showRowTotals) setShowColumnTotals(savedConfig.showColumnTotals) if (savedConfig.calculatedFields) { setCalculatedFields(savedConfig.calculatedFields) } setFieldFilters(savedConfig.fieldFilters ?? {}) } else { // Validate current config const currentConfig: PivotConfig = { rowFields, columnFields, valueFields, showRowTotals, showColumnTotals, } if (!isConfigValidForFields(currentConfig, newKeys)) { // Mirror Vue's clearConfig(): stale filters from the previous // dataset must not silently apply to (and persist under) the new one setRowFieldsState([]) setColumnFieldsState([]) setValueFields([]) setFieldFilters({}) } } // Load collapsed paths from separate sessionStorage key try { const collapsedKey = `${storageKey}-collapsed` const raw = sessionStorage.getItem(collapsedKey) if (raw) { const parsed = JSON.parse(raw) as string[] setCollapsedPaths(new Set(parsed)) } else { setCollapsedPaths(new Set()) } } catch { setCollapsedPaths(new Set()) } } }, [data]) // Save config when it changes useEffect(() => { if (!currentStorageKey) return const config: PivotConfig = { rowFields, columnFields, valueFields, showRowTotals, showColumnTotals, calculatedFields, fieldFilters, } savePivotConfig(currentStorageKey, config) }, [currentStorageKey, rowFields, columnFields, valueFields, showRowTotals, showColumnTotals, calculatedFields, fieldFilters]) // Save collapsedPaths separately when it or the storage key changes useEffect(() => { if (!currentStorageKey) return try { const collapsedKey = `${currentStorageKey}-collapsed` sessionStorage.setItem(collapsedKey, JSON.stringify(Array.from(collapsedPaths))) } catch { // sessionStorage not available (SSR or private browsing) } }, [collapsedPaths, currentStorageKey]) // Actions - pivot is free with sum aggregation, Pro required for other aggregations const addRowField = useCallback( (field: string) => { if (!rowFields.includes(field)) { setRowFieldsState(prev => [...prev, field]) } }, [rowFields], ) const removeRowField = useCallback((field: string) => { setRowFieldsState(prev => prev.filter(f => f !== field)) }, []) const setRowFields = useCallback((fields: string[]) => { setRowFieldsState(fields) }, []) const addColumnField = useCallback( (field: string) => { if (!columnFields.includes(field)) { setColumnFieldsState(prev => [...prev, field]) } }, [columnFields], ) const removeColumnField = useCallback((field: string) => { setColumnFieldsState(prev => prev.filter(f => f !== field)) }, []) const setColumnFields = useCallback((fields: string[]) => { setColumnFieldsState(fields) }, []) const addValueField = useCallback( (field: string, aggregation: AggregationFunction = 'sum') => { // Pro required for non-sum aggregations if (aggregation !== 'sum' && !requirePro(`${aggregation} aggregation`)) { return } setValueFields((prev) => { if (prev.some(v => v.field === field && v.aggregation === aggregation)) { return prev } return [...prev, { field, aggregation }] }) }, [requirePro], ) const removeValueField = useCallback((field: string, aggregation?: AggregationFunction) => { setValueFields((prev) => { if (aggregation) { return prev.filter(v => !(v.field === field && v.aggregation === aggregation)) } return prev.filter(v => v.field !== field) }) }, []) const updateValueFieldAggregation = useCallback( (field: string, oldAgg: AggregationFunction, newAgg: AggregationFunction) => { setValueFields(prev => prev.map((v) => { if (v.field === field && v.aggregation === oldAgg) { return { ...v, aggregation: newAgg } } return v }), ) }, [], ) const clearConfig = useCallback(() => { setRowFieldsState([]) setColumnFieldsState([]) setValueFields([]) setFieldFilters({}) }, []) // Axis value filter management const setFieldFilter = useCallback((field: string, excludedValues: string[]) => { setFieldFilters((prev) => { const next = { ...prev } if (excludedValues.length === 0) { delete next[field] } else { next[field] = [...excludedValues] } return next }) }, []) const clearFieldFilter = useCallback((field: string) => { setFieldFilter(field, []) }, [setFieldFilter]) const autoSuggestConfig = useCallback(() => { if (!requirePro('Pivot Table - Auto Suggest')) return if (availableFields.length === 0) return const categoricalFields = availableFields.filter(f => !f.isNumeric && f.uniqueCount < 50) const numericFields = availableFields.filter(f => f.isNumeric) if (categoricalFields.length > 0 && numericFields.length > 0) { setRowFieldsState([categoricalFields[0].field]) setValueFields([{ field: numericFields[0].field, aggregation: 'sum' }]) } }, [availableFields, requirePro]) // Calculated field management const addCalculatedField = useCallback((field: CalculatedField) => { setCalculatedFields((prev) => { const existing = prev.findIndex(f => f.id === field.id) let updated: CalculatedField[] if (existing >= 0) { updated = [...prev.slice(0, existing), field, ...prev.slice(existing + 1)] } else { updated = [...prev, field] } saveCalculatedFields(updated) return updated }) }, []) const removeCalculatedField = useCallback((id: string) => { setCalculatedFields((prev) => { const updated = prev.filter(f => f.id !== id) saveCalculatedFields(updated) return updated }) // Also remove from value fields if it was being used setValueFields(prev => prev.filter(v => v.field !== `calc:${id}`)) }, []) const toggleCollapsedPath = useCallback( (key: string, altKey: boolean, _rowFields: string[], currentPivotResult: PivotResult | null) => { if (!altKey) { const next = new Set(collapsedPaths) if (next.has(key)) { next.delete(key) } else { next.add(key) } setCollapsedPaths(next) return next } // Alt-click: toggle all groups at the same depth if (!currentPivotResult) return new Set() // Determine which depth this key belongs to by looking at groupStarts let targetDepth = -1 for (const meta of currentPivotResult.rowMeta) { for (const gs of meta.groupStarts) { if (gs.key === key) { targetDepth = gs.depth break } } if (targetDepth >= 0) break } if (targetDepth < 0) return new Set() // Collect all keys at this depth const keysAtDepth = new Set() for (const meta of currentPivotResult.rowMeta) { for (const gs of meta.groupStarts) { if (gs.depth === targetDepth) { keysAtDepth.add(gs.key) } } } // If the clicked key is currently collapsed, expand all; otherwise collapse all const shouldCollapse = !collapsedPaths.has(key) const next = new Set(collapsedPaths) for (const k of keysAtDepth) { if (shouldCollapse) { next.add(k) } else { next.delete(k) } } setCollapsedPaths(next) return next }, [collapsedPaths], ) return { // State rowFields, columnFields, valueFields, showRowTotals, showColumnTotals, calculatedFields, collapsedPaths, fieldFilters, // Computed availableFields, unassignedFields, isConfigured, activeFieldFilters, pivotResult, // Actions addRowField, removeRowField, addColumnField, removeColumnField, addValueField, removeValueField, updateValueFieldAggregation, clearConfig, setShowRowTotals, setShowColumnTotals, autoSuggestConfig, setRowFields, setColumnFields, addCalculatedField, removeCalculatedField, toggleCollapsedPath, setFieldFilter, clearFieldFilter, } }