diff --git a/backend/app/services/counts_table_export.rb b/backend/app/services/counts_table_export.rb
index b17688c..e50d98b 100644
--- a/backend/app/services/counts_table_export.rb
+++ b/backend/app/services/counts_table_export.rb
@@ -167,13 +167,13 @@ class CountsTableExport
cols = @query.axes.columns
leading = [row_count, cols.any? ? 1 : 0].max
columns = page.fetch(:column_headers)
- Array.new([cols.length, 1].max) do |level|
+ headings = Array.new([cols.length, 1].max) do |level|
prefix = Array.new(leading) { level.zero? ? @headers[_1].to_s : '' }
prefix = [''] if row_count.zero? && cols.any?
prefix + columns.each_with_index.map do |header, index|
values = header[:values]
if cols.empty?
- header[:kind] == 'data' ? @labels.fetch('count', '') : total_label(header)
+ @labels.fetch('count', '')
elsif level == values.length && header[:kind] != 'data'
total_label(header)
elsif level < values.length
@@ -188,6 +188,25 @@ class CountsTableExport
end
end
end
+ return headings if cols.empty?
+
+ cols.each_index.flat_map do |level|
+ prefix = Array.new(leading, '')
+ prefix = headings.first.first(leading) if level.zero?
+ names = columns.each_with_index.map do |header, index|
+ next '' if header[:values].length < level
+
+ previous = index.positive? ? columns[index - 1][:values] : nil
+ if (previous && previous.length >= level &&
+ previous.first(level) == header[:values].first(level))
+ ''
+ else
+ @headers[row_count + level].to_s
+ end
+ end
+ headings[level] = Array.new(leading, '') + headings[level].drop(leading) if level.zero?
+ [prefix + names, headings[level]]
+ end
end
def pivot_cell(row, column, page)
diff --git a/frontend/src/components/counts/CountsControls.tsx b/frontend/src/components/counts/CountsControls.tsx
index edb1966..3ac93ee 100644
--- a/frontend/src/components/counts/CountsControls.tsx
+++ b/frontend/src/components/counts/CountsControls.tsx
@@ -40,11 +40,9 @@ const CountsControls = (
const id = useId ()
const motion = useClientMotion ()
const [draft, setDraft] = useState (() => new URLSearchParams (applied))
- const order = applied.get ('order')
- const nestedJSON = applied.get ('nested_json')
- const rowNumbers = applied.get ('row_numbers')
const thumbnail = selectedCountColumns (resource, applied, definition).includes ('thumbnail')
- const previousThumbnail = useRef (thumbnail)
+ const previousApplied = useRef ({ resource, params: new URLSearchParams (applied) })
+ const appliedState = applied.toString ()
const mode = draft.get ('mode') ?? 'summary'
const columns = selectedCountColumns (resource, draft, definition)
const details = mode === 'details'
@@ -53,30 +51,42 @@ const CountsControls = (
&& !countsNestedIsScalar (field.children, field.key, draft)
&& field.children.some (child => selectedCountChildren (draft)
.has (`${field.key}.${child.key}`)))
- // Preserve absent columns until a real presentation change or an explicit edit.
useEffect (() => {
- if (previousThumbnail.current === thumbnail) return
- previousThumbnail.current = thumbnail
- setDraft (previous => {
- const next = new URLSearchParams (previous)
- const columns = selectedCountColumns (resource, previous, definition)
- .filter (column => column !== 'thumbnail')
- next.set ('columns', (thumbnail ? ['thumbnail', ...columns] : columns).join (' '))
- return next
- })
- }, [thumbnail, resource, definition])
- useEffect (() => {
- setDraft (previous => {
- const next = new URLSearchParams (previous)
- const display = { order, nested_json: nestedJSON, row_numbers: rowNumbers }
- for (const [key, value] of Object.entries (display))
+ const nextApplied = new URLSearchParams (appliedState)
+ const previous = previousApplied.current
+ previousApplied.current = { resource, params: nextApplied }
+ setDraft (draft => {
+ if (previous.resource !== resource
+ || previous.params.get ('definition') !== nextApplied.get ('definition'))
+ return nextApplied
+ const keys = new Set ([...previous.params.keys (), ...nextApplied.keys ()])
+ const changed = [...keys].filter (key =>
+ previous.params.get (key) !== nextApplied.get (key))
+ const before = selectedCountColumns (resource, previous.params, definition)
+ const after = selectedCountColumns (resource, nextApplied, definition)
+ const selectionChanged = before.filter (column => column !== 'thumbnail').join (' ')
+ !== after.filter (column => column !== 'thumbnail').join (' ')
+ const internal = ['execution_at', 'timezone', 'order', 'nested_json', 'row_numbers']
+ if (changed.some (key => key === 'columns' ? selectionChanged : !internal.includes (key)))
+ return nextApplied
+ // Internal URL enrichment and immediate display changes preserve pending edits.
+ const next = new URLSearchParams (draft)
+ for (const key of changed)
{
- if (value == null) next.delete (key)
- else next.set (key, value)
+ if (key === 'columns')
+ {
+ const columns = selectedCountColumns (resource, draft, definition)
+ .filter (column => column !== 'thumbnail')
+ next.set ('columns', (after.includes ('thumbnail')
+ ? ['thumbnail', ...columns] : columns).join (' '))
+ continue
+ }
+ next.delete (key)
+ for (const value of nextApplied.getAll (key)) next.append (key, value)
}
- return next
+ return next.toString () === draft.toString () ? draft : next
})
- }, [order, nestedJSON, rowNumbers])
+ }, [appliedState, resource, definition])
const history = draft.get ('definition') === 'history'
const update = (key: string, value: string) => setDraft (previous => {
const next = new URLSearchParams (previous)
diff --git a/frontend/src/components/counts/CountsSummaryResults.tsx b/frontend/src/components/counts/CountsSummaryResults.tsx
index b12b70e..4c4e625 100644
--- a/frontend/src/components/counts/CountsSummaryResults.tsx
+++ b/frontend/src/components/counts/CountsSummaryResults.tsx
@@ -45,6 +45,7 @@ const CountsSummaryTable = (
? 'singleTotal' : 'grandTotal') : 'subtotal').text
const leading = Math.max (rows.length, cols.length > 0 ? 1 : 0)
const levels = Math.max (1, cols.length)
+ const headingLevels = cols.length === 0 ? 1 : levels * 2
const names = (header: CountsPivotHeader, axes: CountsAxis[], offset: number) =>
header.values.map ((value, index) => display (value, axes[index], offset + index))
const columnKey = (header: CountsPivotHeader) => `${header.kind}:${JSON.stringify (header.values)}`
@@ -119,14 +120,13 @@ const CountsSummaryTable = (
if (span === 0) labels[column + (numbered ? 1 : 0)] = ''
}))
// This unmerged matrix is also the single source for TSV and HTML clipboard headings.
- const headingRows = Array.from ({ length: levels }, (_, level) => [
+ const valueHeadingRows = Array.from ({ length: levels }, (_, level) => [
...(numbered ? [level === 0 ? message ('rowNumberHeading').text : ''] : []),
...Array.from ({ length: leading }, (_, index) =>
level === 0 && rows[index] != null
? countsAxisLabel (rows[index], definition, locale).text : ''),
...columnHeaders.map ((header, index) => {
- if (cols.length === 0) return header.kind === 'data'
- ? message ('count').text : aggregateLabel (header)
+ if (cols.length === 0) return message ('count').text
if (level > header.values.length) return ''
if (level === header.values.length)
return header.kind === 'data' ? '' : aggregateLabel (header)
@@ -137,6 +137,48 @@ const CountsSummaryTable = (
return ''
return display (header.values[level], cols[level], rows.length + level)
})])
+ const axisHeadingRows = cols.map ((axis, level) => [
+ ...(numbered ? [''] : []), ...Array.from ({ length: leading }, () => ''),
+ ...columnHeaders.map ((header, index) => {
+ if (header.values.length < level) return ''
+ const previous = columnHeaders[index - 1]
+ const prefix = JSON.stringify (header.values.slice (0, level))
+ return (previous != null && previous.values.length >= level
+ && JSON.stringify (previous.values.slice (0, level)) === prefix)
+ ? '' : countsAxisLabel (axis, definition, locale).text
+ })])
+ const headingRows = cols.length === 0 ? valueHeadingRows
+ : axisHeadingRows.flatMap ((headings, level) => [headings, valueHeadingRows[level]])
+ if (cols.length > 0)
+ {
+ for (let index = 0; index < leading + (numbered ? 1 : 0); ++index)
+ {
+ headingRows[0][index] = valueHeadingRows[0][index]
+ headingRows[1][index] = ''
+ }
+ }
+ const axisHeaderCells = (level: number) => {
+ const output = []
+ for (let index = 0; index < columnHeaders.length; ++index)
+ {
+ const header = columnHeaders[index]
+ if (header.values.length < level) continue
+ const prefix = JSON.stringify (header.values.slice (0, level))
+ let span = 1
+ while (index + span < columnHeaders.length)
+ {
+ const sibling = columnHeaders[index + span]
+ if (sibling.values.length < level
+ || JSON.stringify (sibling.values.slice (0, level)) !== prefix) break
+ ++span
+ }
+ const name = countsAxisLabel (cols[level], definition, locale)
+ output.push (
{name.text} | )
+ index += span - 1
+ }
+ return output
+ }
const matrix = rowHeaders.map ((header, index) => [...rowLabels[index],
...columnHeaders.map (column => String (count (header, column)))])
const headerCells = (level: number) => {
@@ -147,8 +189,10 @@ const CountsSummaryTable = (
if (cols.length > 0 && level > header.values.length) continue
const aggregate = header.kind !== 'data' && level === header.values.length
const leaf = level === levels - 1 || aggregate
- const text = aggregate ? aggregateLabel (header)
- : (cols.length === 0 ? message ('count').text
+ const text =
+ cols.length === 0
+ ? message ('count').text
+ : (aggregate ? aggregateLabel (header)
: display (header.values[level], cols[level], rows.length + level))
let span = 1
if (!leaf)
@@ -165,7 +209,8 @@ const CountsSummaryTable = (
const sortKey = leaf ? measureKey (header) : `a${rows.length + level}`
const field = leaf ? columnKey (header) : sortKey
output.push ( 0) ? (levels - level) * 2 - 1 : 1}
totalKind={header.kind !== 'data' ? header.kind : undefined}
dragId={level === 0 && header.values.length > 0
? dragIds[JSON.stringify (header.values.slice (0, 1))] : undefined}
@@ -184,16 +229,18 @@ const CountsSummaryTable = (
identity={`${countsRequestParams (params)}:${JSON.stringify (keys)}`}
className="text-left text-sm">
- {headingRows.map ((_, level) =>
- {level === 0 && <>
- {numbered && }
+ {headingRows.map ((_, tier) =>
+ {tier === 0 && <>
+ {numbered && }
{Array.from ({ length: leading }, (_, index) => rows[index] == null
- ? |
+ ? |
: )}
>}
- {headerCells (level)}
+ {(cols.length > 0 && tier % 2 === 0)
+ ? axisHeaderCells (tier / 2) : headerCells (Math.floor (tier / 2))}
|
)}
{rowHeaders.map ((row, index) =>
@@ -204,21 +251,19 @@ const CountsSummaryTable = (
role={axis >= 0 ? 'rowheader' : undefined} data-counts-copy={value}
data-counts-aggregate={row.kind !== 'data' ? '' : undefined}
data-counts-total={row.kind !== 'data' ? row.kind : undefined}
+ data-counts-row-total={row.kind !== 'data' ? row.kind : undefined}
className={cn ('overflow-hidden break-words border px-2 py-1 dark:border-slate-700',
numbered && offset === 0 && 'text-right tabular-nums')}>{value}
})}
{columnHeaders.map (column => |
+ className="border px-2 py-1 dark:border-slate-700">
{count (row, column)}
| )}
)}
diff --git a/frontend/src/components/counts/countsTable.css b/frontend/src/components/counts/countsTable.css
index 947cae5..d9b7b88 100644
--- a/frontend/src/components/counts/countsTable.css
+++ b/frontend/src/components/counts/countsTable.css
@@ -18,17 +18,6 @@
@apply bg-slate-900 text-slate-100;
}
-.counts-grid > tbody > tr > td[data-counts-aggregate]:not([data-counts-selected]) {
- background-color: #e2e8f0;
- color: #0f172a;
- font-weight: 600;
-}
-
-.dark .counts-grid > tbody > tr > td[data-counts-aggregate]:not([data-counts-selected]) {
- background-color: #334155;
- color: #f8fafc;
-}
-
.counts-grid > thead {
@apply bg-slate-200 text-slate-950;
}
@@ -55,25 +44,27 @@
@apply border-l-2 border-slate-300 dark:border-slate-600;
}
-.counts-grid > tbody > tr > td[data-counts-total='subtotal']:not([data-counts-selected]),
-.counts-grid > thead > tr > th[data-counts-total='subtotal'] {
+/* Aggregate kind is semantic; only aggregate rows receive a background. */
+.counts-grid > tbody > tr > td[data-counts-total],
+.counts-grid > thead > tr > th[data-counts-total] {
+ @apply border-l border-l-slate-400 font-semibold dark:border-l-slate-500;
+}
+
+.counts-grid > tbody > tr > td[data-counts-row-total='subtotal']:not([data-counts-selected]) {
background-color: #e2e8f0;
color: #0f172a;
font-weight: 600;
}
-.counts-grid > tbody > tr > td[data-counts-total='grand_total']:not([data-counts-selected]),
-.counts-grid > thead > tr > th[data-counts-total='grand_total'] {
+.counts-grid > tbody > tr > td[data-counts-row-total='grand_total']:not([data-counts-selected]) {
background-color: #cbd5e1;
color: #0f172a;
font-weight: 700;
}
-.dark .counts-grid > tbody > tr > td[data-counts-total='subtotal']:not([data-counts-selected]),
-.dark .counts-grid > thead > tr > th[data-counts-total='subtotal'] {
+.dark .counts-grid > tbody > tr > td[data-counts-row-total='subtotal']:not([data-counts-selected]) {
background-color: #334155;
color: #f1f5f9;
}
-.dark .counts-grid > tbody > tr > td[data-counts-total='grand_total']:not([data-counts-selected]),
-.dark .counts-grid > thead > tr > th[data-counts-total='grand_total'] {
+.dark .counts-grid > tbody > tr > td[data-counts-row-total='grand_total']:not([data-counts-selected]) {
background-color: #475569;
color: #fff;
}
diff --git a/frontend/src/components/layout/SidebarComponent.tsx b/frontend/src/components/layout/SidebarComponent.tsx
index a1c5962..762e567 100644
--- a/frontend/src/components/layout/SidebarComponent.tsx
+++ b/frontend/src/components/layout/SidebarComponent.tsx
@@ -15,6 +15,7 @@ type Props = {
children: ReactNode
className?: string
maxWidth?: number
+ maxWidthRatio?: number
onWidthChange?: (width: number) => void
sidebarKey: string
side?: SidebarSide }
@@ -67,6 +68,7 @@ const SidebarComponent: FC = ({
children,
className,
maxWidth,
+ maxWidthRatio,
onWidthChange,
sidebarKey,
side = 'left',
@@ -80,6 +82,7 @@ const SidebarComponent: FC = ({
)
const rootRef = useRef (null)
const maxWidthRef = useRef (maxWidth)
+ const maxWidthRatioRef = useRef (maxWidthRatio)
const getContainerWidth = useCallback (() => {
const containerWidth = rootRef.current
?.closest ('[data-sidebar-container]')
@@ -93,12 +96,14 @@ const SidebarComponent: FC = ({
const viewportWidth =
typeof window === 'undefined' ? containerWidth : window.innerWidth
const viewportMaxWidth = Math.floor (viewportWidth * MAX_VIEWPORT_WIDTH_RATIO)
- const maxAllowedWidth =
- maxWidthRef.current == null
+ const ratioMaxWidth = maxWidthRatioRef.current == null
+ ? Infinity : Math.floor (containerWidth * maxWidthRatioRef.current)
+ const configuredMaxWidth = maxWidthRef.current == null
? getSidebarMaxWidth (containerWidth)
- : Math.max (
- MIN_SIDEBAR_WIDTH,
+ : Math.max (MIN_SIDEBAR_WIDTH,
Math.min (viewportMaxWidth, maxWidthRef.current))
+ const maxAllowedWidth = Math.max (MIN_SIDEBAR_WIDTH,
+ Math.min (ratioMaxWidth, configuredMaxWidth))
return Math.min (maxAllowedWidth, Math.max (MIN_SIDEBAR_WIDTH, nextWidth))
}, [])
@@ -171,10 +176,11 @@ const SidebarComponent: FC = ({
useEffect (() => {
maxWidthRef.current = maxWidth
+ maxWidthRatioRef.current = maxWidthRatio
const containerWidth = getContainerWidth ()
const nextWidth = getClampedWidth (widthRef.current, containerWidth)
applyWidth (nextWidth)
- }, [applyWidth, getClampedWidth, getContainerWidth, maxWidth])
+ }, [applyWidth, getClampedWidth, getContainerWidth, maxWidth, maxWidthRatio])
const updateWidth = useCallback ((clientX: number) => {
const state = resizeRef.current
diff --git a/frontend/src/lib/counts.ts b/frontend/src/lib/counts.ts
index 6328677..01b9afc 100644
--- a/frontend/src/lib/counts.ts
+++ b/frontend/src/lib/counts.ts
@@ -383,6 +383,7 @@ export const countsRequestParams = (params: URLSearchParams): URLSearchParams =>
'current', 'row_numbers'])
result.delete (key)
if (isCountsState (params) && countsUseLatest (params)) result.delete ('at')
+ result.sort ()
return result
}
diff --git a/frontend/src/pages/CountsResourcePage.tsx b/frontend/src/pages/CountsResourcePage.tsx
index 5e6e69c..ffd9e50 100644
--- a/frontend/src/pages/CountsResourcePage.tsx
+++ b/frontend/src/pages/CountsResourcePage.tsx
@@ -1,4 +1,4 @@
-import { useQuery } from '@tanstack/react-query'
+import { useQuery, useQueryClient } from '@tanstack/react-query'
import { useEffect, useRef, useState } from 'react'
import { useSearchParams } from 'react-router-dom'
@@ -38,15 +38,10 @@ import type { CountsResource } from '@/lib/counts'
const CountsResourceContent = ({ resource }: { resource: CountsResource }) => {
const definition = useCountsDefinition ()
+ const queryClient = useQueryClient ()
const [source, setParams] = useSearchParams ()
const params = normaliseCountsParams (resource, source, definition)
const canonical = params.toString ()
- const controlParams = new URLSearchParams (params)
- controlParams.delete ('nested_json')
- controlParams.delete ('order')
- controlParams.delete ('row_numbers')
- controlParams.set ('columns', selectedCountColumns (resource, params, definition)
- .filter (column => column !== 'thumbnail').sort ().join (' '))
const request = countsRequestParams (params)
const [open, setOpen] = useState (false)
const [sidebarOpen, setSidebarOpen] = useState (false)
@@ -65,8 +60,10 @@ const CountsResourceContent = ({ resource }: { resource: CountsResource }) => {
const [exporting, setExporting] = useState (false)
const exportInFlight = useRef (false)
const [exportError, setExportError] = useState (false)
- const { data, isPending, isError, isPlaceholderData, refetch } = useQuery ({
+ const { data, dataUpdatedAt, isFetching, isPending, isError,
+ isPlaceholderData, refetch } = useQuery ({
enabled: params.has ('timezone'),
+ staleTime: params.has ('execution_at') ? 5 * 60 * 1000 : 0,
queryKey: countsKeys.index (resource, cacheParams.toString (), locale),
queryFn: () => fetchCounts (resource, params, locale), retry: false,
placeholderData: (previous, query) => {
@@ -80,25 +77,22 @@ const CountsResourceContent = ({ resource }: { resource: CountsResource }) => {
} })
useEffect (() => {
- if (source.toString () !== canonical)
- setParams (canonical, { replace: true })
- }, [canonical, source, setParams])
-
- useEffect (() => {
- const timezone = Intl.DateTimeFormat ().resolvedOptions ().timeZone
- if (params.get ('timezone') !== timezone)
+ const next = new URLSearchParams (canonical)
+ if (!next.has ('timezone'))
+ next.set ('timezone', Intl.DateTimeFormat ().resolvedOptions ().timeZone)
+ if (data?.executionAt != null && !isPlaceholderData && !isFetching && !isError
+ && params.has ('timezone') && !params.has ('execution_at'))
{
- const next = new URLSearchParams (canonical)
- next.set ('timezone', timezone)
- setParams (next, { replace: true })
- }
- else if (data?.executionAt != null && !isPlaceholderData && !params.has ('execution_at'))
- {
- const next = new URLSearchParams (canonical)
next.set ('execution_at', data.executionAt)
- setParams (next, { replace: true })
+ // Pin the response under its server-confirmed time before changing the URL.
+ queryClient.setQueryData (
+ countsKeys.index (resource, countsRequestParams (next).toString (), locale),
+ data, { updatedAt: dataUpdatedAt })
}
- }, [canonical, data, isPlaceholderData, params, setParams])
+ if (source.toString () !== next.toString ())
+ setParams (next, { replace: true })
+ }, [canonical, data, dataUpdatedAt, isError, isFetching, isPlaceholderData,
+ locale, params, queryClient, resource, setParams, source])
const update = (key: string, value: string, extra?: Record) => {
@@ -166,8 +160,8 @@ const CountsResourceContent = ({ resource }: { resource: CountsResource }) => {
-
+